From 9a50f25f57c72286765abcca42af6133939c109f Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 14 Jul 2026 21:31:16 +0200 Subject: [PATCH] iOS: per-setting sync table with sync icons, dropdowns, colour resets Generalise the two-tier SettingsSync into a server-driven flag map: each syncable key has an account-wide flag (fetched from the server); pull applies only on-flagged keys, push (read-modify-write) sends only on-flagged keys plus the flag map. Per-row link icon toggles a setting's sync; a global switch flips all. Enabling a flag adopts this device's current value as the shared one. - AppSettings: fix bg_color mapping (was background_color), add cache_months, month_view_paged, sync_flags - SettingsView: uniform rows (sync icon | name | value), ContrastSelector and the button pickers replaced by menu dropdowns, every colour gets a reset to a canonical default, global "sync all" toggle; account settings (privacy, group calendar, hide profile) kept in their own block; language and text/line contrast kept device-local (iOS-specific) - unify default line colour to #3A3A52 across views Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/AppSettings.swift | 23 +- Calendarr iOS/Models/CalendarStore.swift | 2 +- Calendarr iOS/Models/Localization.swift | 14 + Calendarr iOS/Services/SettingsSync.swift | 260 ++++---- Calendarr iOS/Views/Calendar/DayView.swift | 4 +- Calendarr iOS/Views/Calendar/MonthView.swift | 2 +- Calendarr iOS/Views/Calendar/WeekView.swift | 4 +- Calendarr iOS/Views/SettingsView.swift | 590 +++++++++---------- 8 files changed, 468 insertions(+), 431 deletions(-) diff --git a/Calendarr iOS/Models/AppSettings.swift b/Calendarr iOS/Models/AppSettings.swift index 3e57518..caa7587 100644 --- a/Calendarr iOS/Models/AppSettings.swift +++ b/Calendarr iOS/Models/AppSettings.swift @@ -15,11 +15,14 @@ struct AppSettings: Codable { var monthLabelColor: String = "#7090c0" var textColor: String = "#FFFFFF" var backgroundColor: String = "#000000" - var lineColor: String = "#3A3A3C" + var lineColor: String = "#3A3A52" var privateEventVisibility: String = "busy" // 'hidden' | 'busy' var groupVisibleCalendarId: Int? = nil var defaultReminderMinutes: Int? = nil // minutes before start; nil = off var defaultEventDurationMinutes: Int = 60 // applied to a new event's end time + var cacheMonths: Int = 3 // preloaded month range (device-local by default) + var monthViewPaged: Bool = false // month view swipe-paging (device-local by default) + var syncFlags: [String: Bool]? = nil // server-resolved per-setting sync flags enum CodingKeys: String, CodingKey { case defaultView = "default_view" @@ -35,21 +38,24 @@ struct AppSettings: Codable { case monthDividerColor = "month_divider_color" case monthLabelColor = "month_label_color" case textColor = "text_color" - case backgroundColor = "background_color" + case backgroundColor = "bg_color" case lineColor = "line_color" case privateEventVisibility = "private_event_visibility" case groupVisibleCalendarId = "group_visible_calendar_id" case defaultReminderMinutes = "default_reminder_minutes" case defaultEventDurationMinutes = "default_event_duration_minutes" + case cacheMonths = "cache_months" + case monthViewPaged = "month_view_paged" + case syncFlags = "sync_flags" } 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. + /// Resilient decoding: a server may omit some keys. Using `decodeIfPresent` + /// with the property defaults means a missing key no longer aborts the whole + /// decode — otherwise the entire settings sync silently breaks. (Note: + /// `background_color` was previously mis-mapped; the server column is + /// `bg_color`, now corrected above.) init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) let d = AppSettings() @@ -72,6 +78,9 @@ struct AppSettings: Codable { groupVisibleCalendarId = try c.decodeIfPresent(Int.self, forKey: .groupVisibleCalendarId) defaultReminderMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultReminderMinutes) defaultEventDurationMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultEventDurationMinutes) ?? d.defaultEventDurationMinutes + cacheMonths = try c.decodeIfPresent(Int.self, forKey: .cacheMonths) ?? d.cacheMonths + monthViewPaged = try c.decodeIfPresent(Bool.self, forKey: .monthViewPaged) ?? d.monthViewPaged + syncFlags = try c.decodeIfPresent([String: Bool].self, forKey: .syncFlags) } } diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 6c63a61..41004b2 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -579,7 +579,7 @@ class CalendarStore { todayColorHex: defaults.string(forKey: "todayColor") ?? "#4285f4", textColorHex: defaults.string(forKey: "textColor") ?? "#FFFFFF", backgroundColorHex: defaults.string(forKey: "backgroundColor") ?? "#000000", - lineColorHex: defaults.string(forKey: "lineColor") ?? "#3A3A3C", + lineColorHex: defaults.string(forKey: "lineColor") ?? "#3A3A52", primaryColorHex: defaults.string(forKey: "primaryColor") ?? "#4285f4", accentColorHex: defaults.string(forKey: "accentColor") ?? "#ea4335", language: defaults.string(forKey: "appLanguage") ?? "system" diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index c8ce99f..4562157 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -81,6 +81,13 @@ private let strings: [String: [String: String]] = [ "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.sync_all": "Alle synchronisieren", + "settings.sync_all.desc": "Diese Einstellungen zwischen deinen Geräten teilen", + "settings.sync_this": "Zwischen Geräten synchronisieren", + "settings.reset": "Zurücksetzen", + "settings.appearance": "Ansicht", + "settings.device": "Nur auf diesem Gerät", + "settings.device.footer": "Diese Einstellungen gelten nur auf diesem Gerät und werden nicht synchronisiert.", "settings.cache.header": "Vorladen", "settings.cache.title": "Vorladen", @@ -440,6 +447,13 @@ private let strings: [String: [String: String]] = [ "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.sync_all": "Sync all", + "settings.sync_all.desc": "Share these settings across your devices", + "settings.sync_this": "Sync across devices", + "settings.reset": "Reset", + "settings.appearance": "View", + "settings.device": "This device only", + "settings.device.footer": "These settings apply to this device only and are not synced.", "settings.cache.header": "Preloading", "settings.cache.title": "Preloading", diff --git a/Calendarr iOS/Services/SettingsSync.swift b/Calendarr iOS/Services/SettingsSync.swift index e324a6b..33027a9 100644 --- a/Calendarr iOS/Services/SettingsSync.swift +++ b/Calendarr iOS/Services/SettingsSync.swift @@ -6,111 +6,185 @@ extension Notification.Name { 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. +/// Per-setting cross-device synchronisation. The server is the sole authority for +/// WHICH settings sync (its `sync_flags` map); this client keeps a cached copy of +/// that map and only sends/applies the keys whose flag is on. See +/// backend/SETTINGS_SYNC.md for the shared contract. /// -/// 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. +/// - Pull: for every syncable key whose flag is on, the server value wins. +/// - Push (debounced, read-modify-write): start from the server snapshot, +/// overwrite only the on-flagged keys with the local value, PUT. enum SettingsSync { - // MARK: – UserDefaults keys + /// Server keys this iOS client can sync, mapped to their UserDefaults key. + /// Intentionally excluded: `language` (iOS "system" has no server value), + /// `share_calendar_icon` (no iOS UI), text/line contrast (iOS-only opacity + /// controls kept device-local), `liquid_glass` (iOS-only). + static let keyToDefaults: [String: String] = [ + "default_view": "defaultView", + "week_start_day": "weekStartDay", + "dim_past_events": "dimPastEvents", + "hour_height": "hourHeight", + "default_event_duration_minutes": "defaultEventDurationMinutes", + "default_reminder_minutes": "defaultReminderMinutes", + "primary_color": "primaryColor", + "accent_color": "accentColor", + "today_color": "todayColor", + "text_color": "textColor", + "bg_color": "backgroundColor", + "line_color": "lineColor", + "month_divider_color": "monthDividerColor", + "month_label_color": "monthLabelColor", + "cache_months": "cacheMonths", + "month_view_paged": "monthViewPaged", + ] - 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" - static let defaultReminder = "defaultReminderMinutes" // Int, -1 = off - static let defaultEventDuration = "defaultEventDurationMinutes" // Int minutes - // master switch - static let enabled = "settingsSync" + static let syncableKeys: [String] = Array(keyToDefaults.keys) + + /// Fallback flags used only until the first server pull populates the real map + /// (mirrors the server's DEFAULT_SYNC for the iOS-managed keys). + static let defaultSync: [String: Bool] = [ + "default_view": true, "week_start_day": true, "dim_past_events": true, + "hour_height": true, "default_event_duration_minutes": true, + "default_reminder_minutes": true, "primary_color": true, "accent_color": true, + "today_color": true, "text_color": true, "bg_color": true, "line_color": true, + "month_divider_color": true, "month_label_color": true, + "cache_months": false, "month_view_paged": false, + ] + + // MARK: – Flag storage (UserDefaults JSON, mirrors the server's resolved map) + + private static let flagsKey = "settingsSyncFlags" + + /// Effective flags: stored overrides on top of the fallback defaults. + static func flags() -> [String: Bool] { + var result = defaultSync + if let data = UserDefaults.standard.data(forKey: flagsKey), + let stored = try? JSONDecoder().decode([String: Bool].self, from: data) { + for (k, v) in stored { result[k] = v } + } + return result } - static var isEnabled: Bool { UserDefaults.standard.bool(forKey: Key.enabled) } + static func isSynced(_ key: String) -> Bool { flags()[key] ?? false } - // 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 writeFlags(_ f: [String: Bool]) { + if let data = try? JSONEncoder().encode(f) { + UserDefaults.standard.set(data, forKey: flagsKey) + } } + + /// Replace the cached flags with the server's resolved map (known keys only). + static func storeServerFlags(_ server: [String: Bool]?) { + guard let server else { return } + var f = flags() + for key in syncableKeys { if let v = server[key] { f[key] = v } } + writeFlags(f) + } + + /// Toggle one setting's sync flag. Turning it on pushes this device's current + /// value up (it becomes the shared value); turning off keeps the value local. + static func setSynced(_ key: String, _ on: Bool, api: CalendarrAPI) { + var f = flags(); f[key] = on; writeFlags(f) + push(api: api) + } + + /// The global "sync everything" switch. + static func setAllSynced(_ on: Bool, api: CalendarrAPI) { + var f = flags(); for key in syncableKeys { f[key] = on }; writeFlags(f) + push(api: api) + } + + // MARK: – Local ⇄ AppSettings field mapping + private static func str(_ key: String, _ fallback: String) -> String { UserDefaults.standard.string(forKey: key) ?? fallback } + private static func int(_ key: String, _ fallback: Int) -> Int { + UserDefaults.standard.object(forKey: key) as? Int ?? fallback + } - // MARK: – Build AppSettings from local UserDefaults - + /// Build an AppSettings snapshot 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) - let rem = int(Key.defaultReminder, -1) + s.primaryColor = str("primaryColor", "#4285f4") + s.accentColor = str("accentColor", "#ea4335") + s.todayColor = str("todayColor", "#4285f4") + s.textColor = str("textColor", "#FFFFFF") + s.backgroundColor = str("backgroundColor", "#000000") + s.lineColor = str("lineColor", "#3A3A52") + s.monthDividerColor = str("monthDividerColor", "#7090c0") + s.monthLabelColor = str("monthLabelColor", "#7090c0") + s.hourHeight = int("hourHeight", 60) + s.defaultView = str("defaultView", "month") + s.weekStartDay = str("weekStartDay", "monday") + s.dimPastEvents = UserDefaults.standard.bool(forKey: "dimPastEvents") + s.cacheMonths = int("cacheMonths", 3) + s.monthViewPaged = UserDefaults.standard.bool(forKey: "monthViewPaged") + let rem = int("defaultReminderMinutes", -1) s.defaultReminderMinutes = rem < 0 ? nil : rem - s.defaultEventDurationMinutes = int(Key.defaultEventDuration, 60) + s.defaultEventDurationMinutes = int("defaultEventDurationMinutes", 60) return s } - // MARK: – Apply a server snapshot to local UserDefaults + /// Copy one synced field from a source snapshot into a destination snapshot. + private static func copyField(_ key: String, from src: AppSettings, into dst: inout AppSettings) { + switch key { + case "default_view": dst.defaultView = src.defaultView + case "week_start_day": dst.weekStartDay = src.weekStartDay + case "dim_past_events": dst.dimPastEvents = src.dimPastEvents + case "hour_height": dst.hourHeight = src.hourHeight + case "default_event_duration_minutes": dst.defaultEventDurationMinutes = src.defaultEventDurationMinutes + case "default_reminder_minutes": dst.defaultReminderMinutes = src.defaultReminderMinutes + case "primary_color": dst.primaryColor = src.primaryColor + case "accent_color": dst.accentColor = src.accentColor + case "today_color": dst.todayColor = src.todayColor + case "text_color": dst.textColor = src.textColor + case "bg_color": dst.backgroundColor = src.backgroundColor + case "line_color": dst.lineColor = src.lineColor + case "month_divider_color": dst.monthDividerColor = src.monthDividerColor + case "month_label_color": dst.monthLabelColor = src.monthLabelColor + case "cache_months": dst.cacheMonths = src.cacheMonths + case "month_view_paged": dst.monthViewPaged = src.monthViewPaged + default: break + } + } - /// Always writes the "always" trio. Writes the optional group only when - /// `includeOptional` is true. - static func apply(_ s: AppSettings, includeOptional: Bool) { + /// Write one synced field from a server snapshot into local UserDefaults. + private static func applyField(_ key: String, from s: AppSettings) { 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) - d.set(s.defaultReminderMinutes ?? -1, forKey: Key.defaultReminder) - d.set(s.defaultEventDurationMinutes, forKey: Key.defaultEventDuration) - 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) + switch key { + case "default_view": d.set(s.defaultView, forKey: "defaultView") + case "week_start_day": d.set(s.weekStartDay, forKey: "weekStartDay") + case "dim_past_events": d.set(s.dimPastEvents, forKey: "dimPastEvents") + case "hour_height": d.set(s.hourHeight, forKey: "hourHeight") + case "default_event_duration_minutes": d.set(s.defaultEventDurationMinutes, forKey: "defaultEventDurationMinutes") + case "default_reminder_minutes": d.set(s.defaultReminderMinutes ?? -1, forKey: "defaultReminderMinutes") + case "primary_color": d.set(s.primaryColor, forKey: "primaryColor") + case "accent_color": d.set(s.accentColor, forKey: "accentColor") + case "today_color": d.set(s.todayColor, forKey: "todayColor") + case "text_color": d.set(s.textColor, forKey: "textColor") + case "bg_color": d.set(s.backgroundColor, forKey: "backgroundColor") + case "line_color": d.set(s.lineColor, forKey: "lineColor") + case "month_divider_color": d.set(s.monthDividerColor, forKey: "monthDividerColor") + case "month_label_color": d.set(s.monthLabelColor, forKey: "monthLabelColor") + case "cache_months": d.set(s.cacheMonths, forKey: "cacheMonths") + case "month_view_paged": d.set(s.monthViewPaged, forKey: "monthViewPaged") + default: break + } } // MARK: – Pull - /// Fetch the server's settings and apply them locally (server wins). + /// Fetch the server's settings, refresh the flag cache, and apply the value of + /// every on-flagged key locally (server wins). static func pull(api: CalendarrAPI) async { guard let server = try? await api.getSettings() else { return } - apply(server, includeOptional: isEnabled) + storeServerFlags(server.syncFlags) + let f = flags() + for key in syncableKeys where f[key] == true { + applyField(key, from: server) + } await MainActor.run { NotificationCenter.default.post(name: .settingsDidChange, object: nil) } @@ -131,31 +205,17 @@ enum SettingsSync { } } - /// 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. + /// Read-modify-write: start from the server's current settings so unsynced + /// fields stay intact, overwrite only the on-flagged keys with local values, + /// and send the current flag map so the account-wide config stays in sync. 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 - merged.defaultReminderMinutes = local.defaultReminderMinutes - merged.defaultEventDurationMinutes = local.defaultEventDurationMinutes - 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 + let f = flags() + for key in syncableKeys where f[key] == true { + copyField(key, from: local, into: &merged) } + merged.syncFlags = f try? await api.updateSettings(merged) } } diff --git a/Calendarr iOS/Views/Calendar/DayView.swift b/Calendarr iOS/Views/Calendar/DayView.swift index e78064c..06a8be0 100644 --- a/Calendarr iOS/Views/Calendar/DayView.swift +++ b/Calendarr iOS/Views/Calendar/DayView.swift @@ -8,7 +8,7 @@ struct DayView: View { @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("lineColor") private var lineHex = "#3A3A52" @AppStorage("textContrast") private var textContrast = 3 @AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout @@ -137,7 +137,7 @@ private struct DayHourSlot: View { let language: String let onCreateEvent: (Date) -> Void - @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("lineColor") private var lineHex = "#3A3A52" @AppStorage("lineContrast") private var lineContrast = 3 private var date: Date { diff --git a/Calendarr iOS/Views/Calendar/MonthView.swift b/Calendarr iOS/Views/Calendar/MonthView.swift index c662b97..f1d6f70 100644 --- a/Calendarr iOS/Views/Calendar/MonthView.swift +++ b/Calendarr iOS/Views/Calendar/MonthView.swift @@ -27,7 +27,7 @@ struct MonthView: View { @AppStorage("monthDividerColor") private var dividerHex = "#7090c0" @AppStorage("monthLabelColor") private var labelHex = "#7090c0" @AppStorage("textColor") private var textHex = "#FFFFFF" - @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("lineColor") private var lineHex = "#3A3A52" @AppStorage("textContrast") private var textContrast = 3 @AppStorage("monthViewPaged") private var monthPaged = false diff --git a/Calendarr iOS/Views/Calendar/WeekView.swift b/Calendarr iOS/Views/Calendar/WeekView.swift index 8fd8a98..cf42141 100644 --- a/Calendarr iOS/Views/Calendar/WeekView.swift +++ b/Calendarr iOS/Views/Calendar/WeekView.swift @@ -10,7 +10,7 @@ struct WeekView: View { @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("lineColor") private var lineHex = "#3A3A52" @AppStorage("textContrast") private var textContrast = 3 @AppStorage("lineContrast") private var lineContrast = 3 @AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout @@ -213,7 +213,7 @@ struct HourSlot: View { let onShowMonth: (Date) -> Void let onShowDay: (Date) -> Void - @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("lineColor") private var lineHex = "#3A3A52" @AppStorage("lineContrast") private var lineContrast = 3 private var date: Date { diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index 74c1a79..e09efbc 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -3,31 +3,30 @@ import SwiftUI struct SettingsView: View { let api: CalendarrAPI @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" - @AppStorage("monthLabelColor") private var labelHex = "#7090c0" - @AppStorage("todayColor") private var todayHex = "#4285f4" + @AppStorage("monthDividerColor") private var dividerHex = "#7090C0" + @AppStorage("monthLabelColor") private var labelHex = "#7090C0" + @AppStorage("todayColor") private var todayHex = "#4285F4" @AppStorage("textColor") private var textHex = "#FFFFFF" @AppStorage("backgroundColor") private var bgHex = "#000000" - @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("lineColor") private var lineHex = "#3A3A52" + @AppStorage("primaryColor") private var primaryHex = "#4285F4" + @AppStorage("accentColor") private var accentHex = "#EA4335" + // iOS-only opacity controls (drive secondary text / grid-line opacity in the + // calendar views); kept device-local, not part of the cross-device sync. @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 - // Device-local: month view as horizontal paged (swipe) vs. scroll feed. @AppStorage("monthViewPaged") private var monthViewPaged = false @AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1 @AppStorage("defaultEventDurationMinutes") private var defaultEventDurationMinutes = 60 - // Profile chapter (server-backed; loaded on appear). + // Profile chapter (server-backed; loaded on appear). Account settings are not + // part of the per-device sync — they are always account-wide. @State private var displayName = "" @State private var loginName = "" @State private var email = "" @@ -37,52 +36,127 @@ struct SettingsView: View { @State private var directoryHidden = false @State private var profileMsg = "" + // Per-setting sync flags (account-wide; refreshed from the server on pull). + @State private var syncFlags: [String: Bool] = SettingsSync.flags() + + // Canonical default colours (single source for the reset buttons). + private static let defaultHex: [String: String] = [ + "primaryColor": "#4285F4", "accentColor": "#EA4335", "todayColor": "#4285F4", + "textColor": "#FFFFFF", "backgroundColor": "#000000", "lineColor": "#3A3A52", + "monthDividerColor": "#7090C0", "monthLabelColor": "#7090C0", + ] + var body: some View { NavigationStack { Form { + globalSyncSection profilSection privatsphaereSection - termindauerSection - benachrichtigungenSection geteilterKalenderSection - liquidGlassSection - cacheSection - spracheSection - farbenSection - schriftSection - linienSection + termineSection ansichtSection - stundenSection + farbenSection + cacheSection + geraetSection } .navigationTitle(L10n.t("settings.title", appLang)) .navigationBarTitleDisplayMode(.large) } - // Reflect the latest server values when opening the screen. - .task { await SettingsSync.pull(api: api) } + // Reflect the latest server values (and flag map) when opening the screen. + .task { await SettingsSync.pull(api: api); syncFlags = SettingsSync.flags() } .task { await loadProfile() } - // 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. + // Value edits update widgets live; a synced value is also pushed (debounced). + // `push` decides what actually gets sent based on each key's flag. .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: dividerHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) } + .onChange(of: labelHex) { _, _ in WidgetStore.republishAppearanceOnly(); 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: monthViewPaged){ _, _ in SettingsSync.push(api: api) } + .onChange(of: cacheMonths) { _, _ in SettingsSync.push(api: api) } + .onChange(of: defaultEventDurationMinutes) { _, _ 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: – Profil + // MARK: – Reusable row builders + + /// Leading per-row sync toggle: linked (accent) = synced across devices. + @ViewBuilder + private func syncIcon(_ key: String) -> some View { + let on = syncFlags[key] == true + Button { + SettingsSync.setSynced(key, !on, api: api) + syncFlags = SettingsSync.flags() + } label: { + Image(systemName: on ? "link.circle.fill" : "link.circle") + .imageScale(.large) + .foregroundStyle(on ? Color.accentColor : Color.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(L10n.t("settings.sync_this", appLang)) + } + + private func colorBinding(_ hex: Binding) -> Binding { + Binding(get: { Color(hex: hex.wrappedValue) }, set: { hex.wrappedValue = $0.toHex() }) + } + + @ViewBuilder + private func colorRow(_ syncKey: String, _ defaultsKey: String, _ label: String, _ hex: Binding) -> some View { + HStack(spacing: 12) { + syncIcon(syncKey) + Text(label) + Spacer() + ColorPicker("", selection: colorBinding(hex), supportsOpacity: false) + .labelsHidden() + Text(hex.wrappedValue.uppercased()) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 64, alignment: .trailing) + Button { + hex.wrappedValue = Self.defaultHex[defaultsKey] ?? "#000000" + } label: { + Image(systemName: "arrow.uturn.backward") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.t("settings.reset", appLang)) + } + } + + // MARK: – Global sync + + var globalSyncSection: some View { + Section { + Toggle(isOn: allSyncedBinding) { + Label { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.t("settings.sync_all", appLang)) + Text(L10n.t("settings.sync_all.desc", appLang)) + .font(.caption).foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "arrow.triangle.2.circlepath").foregroundStyle(.teal) + } + } + .tint(Color.accentColor) + } + } + + private var allSyncedBinding: Binding { + Binding( + get: { SettingsSync.syncableKeys.allSatisfy { syncFlags[$0] == true } }, + set: { on in SettingsSync.setAllSynced(on, api: api); syncFlags = SettingsSync.flags() } + ) + } + + // MARK: – Profil (account identity — not synced) var profilSection: some View { Section(L10n.t("settings.nav.profile", appLang)) { @@ -119,28 +193,7 @@ struct SettingsView: View { } } - // MARK: – Benachrichtigungen - - var benachrichtigungenSection: some View { - Section { - Picker(ReminderOptions.defaultTitle(appLang), selection: $defaultReminderMinutes) { - Text(ReminderOptions.off(appLang)).tag(-1) - ForEach(ReminderOptions.all, id: \.self) { m in - Text(ReminderOptions.label(m, appLang)).tag(m) - } - } - .onChange(of: defaultReminderMinutes) { _, _ in - SettingsSync.push(api: api) - NotificationCenter.default.post(name: .rescheduleReminders, object: nil) - } - } header: { - Text(ReminderOptions.sectionTitle(appLang)) - } footer: { - Text(ReminderOptions.defaultFooter(appLang)).font(.caption) - } - } - - // MARK: – Privatsphäre + // MARK: – Privatsphäre (account — not synced) var privatsphaereSection: some View { Section { @@ -158,22 +211,7 @@ struct SettingsView: View { } } - // MARK: – Standardtermindauer - - var termindauerSection: some View { - Section { - Picker(L10n.t("settings.default_duration", appLang), selection: $defaultEventDurationMinutes) { - ForEach([15, 30, 45, 60, 90, 120, 240], id: \.self) { m in - Text(ReminderOptions.durationLabel(m, appLang)).tag(m) - } - } - .onChange(of: defaultEventDurationMinutes) { _, _ in SettingsSync.push(api: api) } - } header: { - Text(L10n.t("settings.calview", appLang)) - } - } - - // MARK: – Geteilter Kalender + // MARK: – Geteilter Kalender (account — not synced) var geteilterKalenderSection: some View { Section { @@ -193,6 +231,170 @@ struct SettingsView: View { } } + // MARK: – Termine (synced) + + var termineSection: some View { + Section(L10n.t("settings.calview", appLang)) { + HStack(spacing: 12) { + syncIcon("default_event_duration_minutes") + Text(L10n.t("settings.default_duration", appLang)) + Spacer() + Picker("", selection: $defaultEventDurationMinutes) { + ForEach([15, 30, 45, 60, 90, 120, 240], id: \.self) { m in + Text(ReminderOptions.durationLabel(m, appLang)).tag(m) + } + } + .pickerStyle(.menu).labelsHidden() + } + HStack(spacing: 12) { + syncIcon("default_reminder_minutes") + Text(ReminderOptions.defaultTitle(appLang)) + Spacer() + Picker("", selection: $defaultReminderMinutes) { + Text(ReminderOptions.off(appLang)).tag(-1) + ForEach(ReminderOptions.all, id: \.self) { m in + Text(ReminderOptions.label(m, appLang)).tag(m) + } + } + .pickerStyle(.menu).labelsHidden() + .onChange(of: defaultReminderMinutes) { _, _ in + SettingsSync.push(api: api) + NotificationCenter.default.post(name: .rescheduleReminders, object: nil) + } + } + } + } + + // MARK: – Ansicht (synced) + + var ansichtSection: some View { + Section(L10n.t("settings.appearance", appLang)) { + HStack(spacing: 12) { + syncIcon("default_view") + Text(L10n.t("settings.defaultview", appLang)) + Spacer() + Picker("", 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") + } + .pickerStyle(.menu).labelsHidden() + } + HStack(spacing: 12) { + syncIcon("week_start_day") + Text(L10n.t("settings.firstweekday", appLang)) + Spacer() + Picker("", selection: $weekStartDay) { + Text(L10n.t("settings.monday", appLang)).tag("monday") + Text(L10n.t("settings.sunday", appLang)).tag("sunday") + } + .pickerStyle(.menu).labelsHidden() + } + HStack(spacing: 12) { + syncIcon("dim_past_events") + Toggle(L10n.t("settings.dimpast", appLang), isOn: $dimPastEvents).tint(Color.accentColor) + } + HStack(spacing: 12) { + syncIcon("month_view_paged") + Toggle(L10n.t("settings.month_paged", appLang), isOn: $monthViewPaged).tint(Color.accentColor) + } + HStack(spacing: 12) { + syncIcon("hour_height") + Text(L10n.t("settings.hourheight", appLang)) + Spacer() + Picker("", selection: $hourHeight) { + Text(L10n.t("settings.hourheight.compact", appLang)).tag(28) + Text(L10n.t("settings.hourheight.normal", appLang)).tag(44) + Text(L10n.t("settings.hourheight.comfort", appLang)).tag(60) + Text(L10n.t("settings.hourheight.large", appLang)).tag(80) + } + .pickerStyle(.menu).labelsHidden() + } + } + } + + // MARK: – Farben (synced) + + var farbenSection: some View { + Section(L10n.t("settings.colors", appLang)) { + colorRow("primary_color", "primaryColor", L10n.t("settings.color.primary", appLang), $primaryHex) + colorRow("accent_color", "accentColor", L10n.t("settings.color.accent", appLang), $accentHex) + colorRow("today_color", "todayColor", L10n.t("settings.color.today", appLang), $todayHex) + colorRow("text_color", "textColor", L10n.t("settings.color.text", appLang), $textHex) + colorRow("bg_color", "backgroundColor", L10n.t("settings.color.background", appLang), $bgHex) + colorRow("line_color", "lineColor", L10n.t("settings.color.line", appLang), $lineHex) + colorRow("month_divider_color", "monthDividerColor", L10n.t("settings.color.divider", appLang), $dividerHex) + colorRow("month_label_color", "monthLabelColor", L10n.t("settings.color.label", appLang), $labelHex) + } + } + + // MARK: – Cache (synced) + + var cacheSection: some View { + Section { + HStack(spacing: 12) { + syncIcon("cache_months") + Text(L10n.t("settings.cache.range", appLang)) + Spacer() + Picker("", selection: $cacheMonths) { + Text(L10n.t("settings.cache.1m", appLang)).tag(1) + Text(L10n.t("settings.cache.3m", appLang)).tag(3) + Text(L10n.t("settings.cache.6m", appLang)).tag(6) + Text(L10n.t("settings.cache.1y", appLang)).tag(12) + } + .pickerStyle(.menu).labelsHidden() + } + } header: { + Text(L10n.t("settings.cache.header", appLang)) + } footer: { + Text(L10n.t("settings.cache.footer", appLang)).font(.caption) + } + } + + // MARK: – Gerät (device-local: language, contrast, liquid glass) + + var geraetSection: some View { + Section { + Picker(L10n.t("settings.language", appLang), selection: $appLang) { + Text(L10n.t("lang.system", appLang)).tag("system") + Text(L10n.t("lang.german", appLang)).tag("de") + Text(L10n.t("lang.english", appLang)).tag("en") + } + Picker(L10n.t("settings.textcontrast", appLang), selection: $textContrast) { + Text(L10n.t("settings.contrast.dark", appLang)).tag(1) + Text(L10n.t("settings.contrast.medium", appLang)).tag(2) + Text(L10n.t("settings.contrast.bright", appLang)).tag(3) + Text(L10n.t("settings.contrast.max", appLang)).tag(4) + } + Picker(L10n.t("settings.linecontrast", appLang), selection: $lineContrast) { + Text(L10n.t("settings.linecontrast.barely", appLang)).tag(1) + Text(L10n.t("settings.linecontrast.subtle", appLang)).tag(2) + Text(L10n.t("settings.linecontrast.normal", appLang)).tag(3) + Text(L10n.t("settings.linecontrast.strong", appLang)).tag(4) + } + Toggle(isOn: $liquidGlass) { + Label { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.t("settings.liquidglass", appLang)) + Text(L10n.t("settings.liquidglass.desc", appLang)) + .font(.caption).foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "sparkles").foregroundStyle(.blue) + } + } + .tint(Color.accentColor) + } header: { + Text(L10n.t("settings.device", appLang)) + } footer: { + Text(L10n.t("settings.device.footer", appLang)).font(.caption) + } + } + + // MARK: – Profile load / save + private func loadProfile() async { if let p = try? await api.getProfile() { displayName = p.displayName ?? p.username @@ -223,252 +425,4 @@ struct SettingsView: View { profileMsg = error.localizedDescription } } - - // MARK: – Liquid Glass - - var liquidGlassSection: some View { - Section { - Toggle(isOn: $liquidGlass) { - Label { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.t("settings.liquidglass", appLang)) - Text(L10n.t("settings.liquidglass.desc", appLang)) - .font(.caption) - .foregroundStyle(.secondary) - } - } icon: { - Image(systemName: "sparkles") - .foregroundStyle(.blue) - } - } - .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.sync.footer", appLang)) - .font(.caption) - } - } - - // MARK: – Cache - - var cacheSection: some View { - Section { - VStack(alignment: .leading, spacing: 10) { - Label { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.t("settings.cache.title", appLang)) - Text(L10n.t("settings.cache.desc", appLang)) - .font(.caption) - .foregroundStyle(.secondary) - } - } icon: { - Image(systemName: "arrow.down.circle") - .foregroundStyle(.green) - } - - Picker(L10n.t("settings.cache.range", appLang), selection: $cacheMonths) { - Text(L10n.t("settings.cache.1m", appLang)).tag(1) - Text(L10n.t("settings.cache.3m", appLang)).tag(3) - Text(L10n.t("settings.cache.6m", appLang)).tag(6) - Text(L10n.t("settings.cache.1y", appLang)).tag(12) - } - .pickerStyle(.segmented) - } - .padding(.vertical, 4) - } header: { - Text(L10n.t("settings.cache.header", appLang)) - } footer: { - Text(L10n.t("settings.cache.footer", appLang)) - .font(.caption) - } - } - - // MARK: – Sprache - - var spracheSection: some View { - Section(L10n.t("settings.language", appLang)) { - Picker(L10n.t("settings.language", appLang), selection: $appLang) { - Text(L10n.t("lang.system", appLang)).tag("system") - Text(L10n.t("lang.german", appLang)).tag("de") - Text(L10n.t("lang.english", appLang)).tag("en") - } - } - } - - // MARK: – Farben - - var farbenSection: some View { - Section(L10n.t("settings.colors", appLang)) { - ColorPickerRow(label: L10n.t("settings.color.primary", appLang), hex: $primaryHex) - ColorPickerRow(label: L10n.t("settings.color.accent", appLang), hex: $accentHex) - ColorPickerRow(label: L10n.t("settings.color.today", appLang), hex: $todayHex) - ColorPickerRow(label: L10n.t("settings.color.text", appLang), hex: $textHex) - ColorPickerRow(label: L10n.t("settings.color.background", appLang), hex: $bgHex) - ColorPickerRow(label: L10n.t("settings.color.line", appLang), hex: $lineHex) - ColorPickerRow(label: L10n.t("settings.color.divider", appLang), hex: $dividerHex) - ColorPickerRow(label: L10n.t("settings.color.label", appLang), hex: $labelHex) - } - } - - // MARK: – Schriftkontrast - - var schriftSection: some View { - Section { - VStack(alignment: .leading, spacing: 10) { - Text(L10n.t("settings.textcontrast", appLang)) - .font(.headline) - Text(L10n.t("settings.textcontrast.desc", appLang)) - .font(.caption) - .foregroundStyle(.secondary) - ContrastSelector( - value: $textContrast, - options: [ - (1, L10n.t("settings.contrast.dark", appLang)), - (2, L10n.t("settings.contrast.medium", appLang)), - (3, L10n.t("settings.contrast.bright", appLang)), - (4, L10n.t("settings.contrast.max", appLang)) - ] - ) - } - .padding(.vertical, 4) - } - } - - // MARK: – Linienkontrast - - var linienSection: some View { - Section { - VStack(alignment: .leading, spacing: 10) { - Text(L10n.t("settings.linecontrast", appLang)) - .font(.headline) - Text(L10n.t("settings.linecontrast.desc", appLang)) - .font(.caption) - .foregroundStyle(.secondary) - ContrastSelector( - value: $lineContrast, - options: [ - (1, L10n.t("settings.linecontrast.barely", appLang)), - (2, L10n.t("settings.linecontrast.subtle", appLang)), - (3, L10n.t("settings.linecontrast.normal", appLang)), - (4, L10n.t("settings.linecontrast.strong", appLang)) - ] - ) - } - .padding(.vertical, 4) - } - } - - // MARK: – Ansicht - - var ansichtSection: some View { - Section(L10n.t("settings.calview", appLang)) { - 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: $weekStartDay) { - Text(L10n.t("settings.monday", appLang)).tag("monday") - Text(L10n.t("settings.sunday", appLang)).tag("sunday") - } - Toggle(L10n.t("settings.dimpast", appLang), isOn: $dimPastEvents) - .tint(Color.accentColor) - Toggle(L10n.t("settings.month_paged", appLang), isOn: $monthViewPaged) - .tint(Color.accentColor) - } - } - - // MARK: – Stundenhöhe - - var stundenSection: some View { - Section { - VStack(alignment: .leading, spacing: 10) { - Text(L10n.t("settings.hourheight", appLang)) - .font(.headline) - Text(L10n.t("settings.hourheight.desc", appLang)) - .font(.caption) - .foregroundStyle(.secondary) - ContrastSelector( - value: $hourHeight, - options: [ - (28, L10n.t("settings.hourheight.compact", appLang)), - (44, L10n.t("settings.hourheight.normal", appLang)), - (60, L10n.t("settings.hourheight.comfort", appLang)), - (80, L10n.t("settings.hourheight.large", appLang)) - ] - ) - } - .padding(.vertical, 4) - } - } - -} - -// MARK: – Reusable Components - -struct ColorPickerRow: View { - let label: String - @Binding var hex: String - - var color: Binding { - Binding( - get: { Color(hex: hex) }, - set: { hex = $0.toHex() } - ) - } - - var body: some View { - HStack { - Text(label) - Spacer() - ColorPicker("", selection: color, supportsOpacity: false) - .labelsHidden() - Text(hex.uppercased()) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 68, alignment: .trailing) - } - } -} - -struct ContrastSelector: View { - @Binding var value: T - let options: [(T, String)] - - var body: some View { - HStack(spacing: 8) { - ForEach(Array(options.enumerated()), id: \.offset) { _, opt in - Button { - value = opt.0 - } label: { - Text(opt.1) - .font(.caption.weight(.medium)) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(value == opt.0 ? Color.accentColor : Color(.systemGray5)) - .foregroundStyle(value == opt.0 ? .white : .primary) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - .buttonStyle(.plain) - } - } - } }