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;
|
||||
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;
|
||||
|
||||
@@ -34,6 +34,33 @@ struct AppSettings: Codable {
|
||||
case backgroundColor = "background_color"
|
||||
case lineColor = "line_color"
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
/// Resilient decoding: the server only stores a subset of these fields
|
||||
/// (e.g. it has no `text_color`/`background_color`/`line_color`, which are
|
||||
/// iOS-only). Using `decodeIfPresent` with the property defaults means a
|
||||
/// missing key no longer aborts the whole decode — otherwise the entire
|
||||
/// settings sync silently breaks.
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let d = AppSettings()
|
||||
defaultView = try c.decodeIfPresent(String.self, forKey: .defaultView) ?? d.defaultView
|
||||
weekStartDay = try c.decodeIfPresent(String.self, forKey: .weekStartDay) ?? d.weekStartDay
|
||||
primaryColor = try c.decodeIfPresent(String.self, forKey: .primaryColor) ?? d.primaryColor
|
||||
accentColor = try c.decodeIfPresent(String.self, forKey: .accentColor) ?? d.accentColor
|
||||
todayColor = try c.decodeIfPresent(String.self, forKey: .todayColor) ?? d.todayColor
|
||||
dimPastEvents = try c.decodeIfPresent(Bool.self, forKey: .dimPastEvents) ?? d.dimPastEvents
|
||||
textContrast = try c.decodeIfPresent(Int.self, forKey: .textContrast) ?? d.textContrast
|
||||
lineContrast = try c.decodeIfPresent(Int.self, forKey: .lineContrast) ?? d.lineContrast
|
||||
hourHeight = try c.decodeIfPresent(Int.self, forKey: .hourHeight) ?? d.hourHeight
|
||||
language = try c.decodeIfPresent(String.self, forKey: .language) ?? d.language
|
||||
monthDividerColor = try c.decodeIfPresent(String.self, forKey: .monthDividerColor) ?? d.monthDividerColor
|
||||
monthLabelColor = try c.decodeIfPresent(String.self, forKey: .monthLabelColor) ?? d.monthLabelColor
|
||||
textColor = try c.decodeIfPresent(String.self, forKey: .textColor) ?? d.textColor
|
||||
backgroundColor = try c.decodeIfPresent(String.self, forKey: .backgroundColor) ?? d.backgroundColor
|
||||
lineColor = try c.decodeIfPresent(String.self, forKey: .lineColor) ?? d.lineColor
|
||||
}
|
||||
}
|
||||
|
||||
struct CalDAVAccount: Codable, Identifiable {
|
||||
@@ -124,10 +151,22 @@ struct HACalendar: Codable, Identifiable {
|
||||
var entityId: String
|
||||
var color: String?
|
||||
var enabled: Bool
|
||||
var sidebarHidden: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, color, enabled
|
||||
case entityId = "entity_id"
|
||||
case sidebarHidden = "sidebar_hidden"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(Int.self, forKey: .id)
|
||||
name = try c.decode(String.self, forKey: .name)
|
||||
entityId = try c.decodeIfPresent(String.self, forKey: .entityId) ?? ""
|
||||
color = try c.decodeIfPresent(String.self, forKey: .color)
|
||||
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
|
||||
sidebarHidden = try c.decodeIfPresent(Bool.self, forKey: .sidebarHidden) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@ extension Notification.Name {
|
||||
/// outside the active `CalendarStore` (e.g. by `AccountsView`). The store
|
||||
/// listens for this in `CalendarHostView` and refreshes its filter.
|
||||
static let banishedCalendarsChanged = Notification.Name("banishedCalendarsChanged")
|
||||
|
||||
/// Posted when the user taps the manual "sync with server" button in the
|
||||
/// menu. `CalendarHostView` responds by invalidating the cache and
|
||||
/// re-fetching events from the server.
|
||||
static let manualSyncRequested = Notification.Name("manualSyncRequested")
|
||||
}
|
||||
|
||||
enum CalViewType: String, CaseIterable {
|
||||
@@ -107,7 +112,16 @@ class CalendarStore {
|
||||
}
|
||||
|
||||
static func calendarKey(source: String, calendarId: String) -> String {
|
||||
"\(source):\(calendarId)"
|
||||
// The events API returns `calendar_id` inconsistently: a raw numeric for
|
||||
// CalDAV, but "<source>-<id>" for local / ical / google / homeassistant
|
||||
// (e.g. "local-3", "google-5"). The filter UI keys off the numeric DB id,
|
||||
// so strip any leading "<source>-" prefix to make event keys and filter
|
||||
// keys comparable — otherwise local hiding/banishing silently does nothing
|
||||
// for those sources.
|
||||
var id = calendarId
|
||||
let prefix = "\(source)-"
|
||||
if id.hasPrefix(prefix) { id = String(id.dropFirst(prefix.count)) }
|
||||
return "\(source):\(id)"
|
||||
}
|
||||
|
||||
// MARK: – Banished-calendar persistence
|
||||
@@ -149,6 +163,18 @@ class CalendarStore {
|
||||
publishWidgetSnapshot()
|
||||
}
|
||||
|
||||
/// Replace the whole banished set (used when reconciling with the server's
|
||||
/// `sidebar_hidden` flags). Persists, notifies, refreshes.
|
||||
func setBanishedCalendars(_ keys: Set<String>) {
|
||||
guard keys != banishedCalendarKeys else { return }
|
||||
banishedCalendarKeys = keys
|
||||
Self.saveBanishedKeys(keys)
|
||||
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
|
||||
let (s, e) = rangeForCurrentView()
|
||||
refreshFromCache(start: s, end: e)
|
||||
publishWidgetSnapshot()
|
||||
}
|
||||
|
||||
/// Re-read the banished set from UserDefaults – called when an external
|
||||
/// view (AccountsView) mutated it. Refreshes visible events + widgets.
|
||||
func syncBanishedFromDefaults() {
|
||||
@@ -158,6 +184,17 @@ class CalendarStore {
|
||||
publishWidgetSnapshot()
|
||||
}
|
||||
|
||||
/// Split a `"source:calendarId"` key back into its parts.
|
||||
static func parseCalendarKey(_ key: String) -> (source: String, id: Int)? {
|
||||
guard let colon = key.firstIndex(of: ":") else { return nil }
|
||||
let source = String(key[..<colon])
|
||||
guard let id = Int(key[key.index(after: colon)...]) else { return nil }
|
||||
return (source, id)
|
||||
}
|
||||
|
||||
/// Sources whose visibility is backed by the server's `sidebar_hidden`.
|
||||
static let serverManagedSources: Set<String> = ["caldav", "google", "homeassistant"]
|
||||
|
||||
var userCalendar: Calendar {
|
||||
var cal = Calendar.current
|
||||
cal.firstWeekday = weekStartsOnMonday ? 2 : 1
|
||||
@@ -186,11 +223,23 @@ class CalendarStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Optimistically drop a just-deleted event from the cache so it disappears
|
||||
/// from the UI immediately, without waiting for a server round-trip (HA
|
||||
/// deletes can lag several seconds, and an immediate refetch could even
|
||||
/// re-add it before the source propagated the deletion).
|
||||
func removeCachedEvent(id: String) {
|
||||
allCachedEvents.removeAll { $0.id == id }
|
||||
events.removeAll { $0.id == id }
|
||||
publishWidgetSnapshot()
|
||||
}
|
||||
|
||||
// MARK: – Network loading
|
||||
|
||||
/// Load events for a specific range – skips network if already cached.
|
||||
func loadEvents(api: CalendarrAPI, start: Date, end: Date) async {
|
||||
if isCached(start: start, end: end) {
|
||||
/// Load events for a specific range. Skips the network if already cached,
|
||||
/// unless `force` is set (used after create/edit to pull fresh server data
|
||||
/// for the visible range, bypassing the cache).
|
||||
func loadEvents(api: CalendarrAPI, start: Date, end: Date, force: Bool = false) async {
|
||||
if !force, isCached(start: start, end: end) {
|
||||
refreshFromCache(start: start, end: end)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ private let strings: [String: [String: String]] = [
|
||||
"menu.server": "Server",
|
||||
"menu.logout": "Abmelden",
|
||||
"menu.admin": "Admin",
|
||||
"menu.sync": "Mit Server synchronisieren",
|
||||
"menu.sync.section": "Synchronisierung",
|
||||
|
||||
// Settings – chrome
|
||||
"settings.title": "Darstellung",
|
||||
@@ -76,6 +78,9 @@ private let strings: [String: [String: String]] = [
|
||||
"settings.liquidglass": "Liquid Glass",
|
||||
"settings.liquidglass.desc": "Verwendet die neue iOS\u{202F}26 Glasoptik mit transparenter Navigationsleiste",
|
||||
"settings.liquidglass.footer": "Änderung wirkt sofort – kein Neustart nötig.",
|
||||
"settings.sync": "Einstellungen synchronisieren",
|
||||
"settings.sync.desc": "Darstellung mit dem Server abgleichen",
|
||||
"settings.sync.footer": "Wenn aktiv, werden Farben, Kontraste und Stundenhöhe mit dem Server abgeglichen (der Server hat Vorrang). Ansicht, erster Wochentag und das Ausgrauen vergangener Termine werden immer synchronisiert – auch wenn der Schalter aus ist.",
|
||||
|
||||
"settings.cache.header": "Vorladen",
|
||||
"settings.cache.title": "Vorladen",
|
||||
@@ -320,6 +325,8 @@ private let strings: [String: [String: String]] = [
|
||||
"menu.server": "Server",
|
||||
"menu.logout": "Sign out",
|
||||
"menu.admin": "Admin",
|
||||
"menu.sync": "Sync with server",
|
||||
"menu.sync.section": "Synchronization",
|
||||
|
||||
"settings.title": "Appearance",
|
||||
"settings.loading": "Loading settings…",
|
||||
@@ -330,6 +337,9 @@ private let strings: [String: [String: String]] = [
|
||||
"settings.liquidglass": "Liquid Glass",
|
||||
"settings.liquidglass.desc": "Uses the new iOS\u{202F}26 glass look with a translucent navigation bar",
|
||||
"settings.liquidglass.footer": "Takes effect immediately – no restart required.",
|
||||
"settings.sync": "Sync settings",
|
||||
"settings.sync.desc": "Keep appearance in sync with the server",
|
||||
"settings.sync.footer": "When on, colors, contrasts and hour height sync with the server (the server wins). View, first weekday and dimming past events always sync – even when the switch is off.",
|
||||
|
||||
"settings.cache.header": "Preloading",
|
||||
"settings.cache.title": "Preloading",
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,54 @@
|
||||
import SwiftUI
|
||||
|
||||
// Shared constants used by WeekView, DayView, EventEditorSheet
|
||||
let hourHeight: CGFloat = 60
|
||||
let timeColumnWidth: CGFloat = 44
|
||||
let hours = Array(0..<24)
|
||||
|
||||
/// Live hour-row height, driven by the synced `hourHeight` setting.
|
||||
/// Falls back to 60 when unset (fresh install / value 0). Views that lay out
|
||||
/// against this also observe `@AppStorage("hourHeight")` so their body
|
||||
/// re-renders when it changes.
|
||||
var hourHeight: CGFloat {
|
||||
let v = UserDefaults.standard.integer(forKey: "hourHeight")
|
||||
return v > 0 ? CGFloat(v) : 60
|
||||
}
|
||||
|
||||
/// Opacity for secondary text (weekday headers, time labels, "+N"/"KW"),
|
||||
/// mapped from the 1–4 `textContrast` level. Level 3 ≈ the previous hard-coded
|
||||
/// look so existing installs are visually unchanged.
|
||||
func secondaryTextOpacity(_ level: Int) -> Double {
|
||||
switch level {
|
||||
case 1: return 0.4
|
||||
case 2: return 0.55
|
||||
case 4: return 1.0
|
||||
default: return 0.75
|
||||
}
|
||||
}
|
||||
|
||||
/// Opacity for grid lines / separators, mapped from the 1–4 `lineContrast`
|
||||
/// level. Level 3 ≈ the previous hard-coded ~0.4 look.
|
||||
func gridLineOpacity(_ level: Int) -> Double {
|
||||
switch level {
|
||||
case 1: return 0.15
|
||||
case 2: return 0.3
|
||||
case 4: return 0.8
|
||||
default: return 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// A timed (non-all-day) event that crosses a day boundary. Such events must
|
||||
/// NOT be placed in the hourly grid — their height would be `duration ×
|
||||
/// hourHeight`, i.e. taller than the whole day, rendering as a giant block
|
||||
/// (and, sharing one id across days, only drawing on the first day). They are
|
||||
/// shown in the all-day strip instead, like all-day events.
|
||||
func eventSpansMultipleDays(_ ev: CalEvent) -> Bool {
|
||||
guard !ev.isAllDay, ev.endDate > ev.startDate else { return false }
|
||||
let cal = Calendar.current
|
||||
// End is exclusive: an event ending exactly at midnight is still single-day.
|
||||
let lastInstant = ev.endDate.addingTimeInterval(-1)
|
||||
return !cal.isDate(ev.startDate, inSameDayAs: lastInstant)
|
||||
}
|
||||
|
||||
// Position helpers
|
||||
func eventTop(_ ev: CalEvent) -> CGFloat {
|
||||
let cal = Calendar.current
|
||||
@@ -21,6 +65,9 @@ func eventHeight(_ ev: CalEvent) -> CGFloat {
|
||||
// Shared event block used in WeekView and DayView
|
||||
struct EventBlock: View {
|
||||
let event: CalEvent
|
||||
@AppStorage("dimPastEvents") private var dimPast = false
|
||||
|
||||
private var isPast: Bool { event.endDate < .now }
|
||||
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
@@ -41,5 +88,6 @@ struct EventBlock: View {
|
||||
.padding(4)
|
||||
}
|
||||
.padding(.horizontal, 1)
|
||||
.opacity(dimPast && isPast ? 0.5 : 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user