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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-14 21:31:16 +02:00
parent d4fd7beaf7
commit 9a50f25f57
8 changed files with 468 additions and 431 deletions

View File

@@ -15,11 +15,14 @@ struct AppSettings: Codable {
var monthLabelColor: String = "#7090c0" var monthLabelColor: String = "#7090c0"
var textColor: String = "#FFFFFF" var textColor: String = "#FFFFFF"
var backgroundColor: String = "#000000" var backgroundColor: String = "#000000"
var lineColor: String = "#3A3A3C" var lineColor: String = "#3A3A52"
var privateEventVisibility: String = "busy" // 'hidden' | 'busy' var privateEventVisibility: String = "busy" // 'hidden' | 'busy'
var groupVisibleCalendarId: Int? = nil var groupVisibleCalendarId: Int? = nil
var defaultReminderMinutes: Int? = nil // minutes before start; nil = off var defaultReminderMinutes: Int? = nil // minutes before start; nil = off
var defaultEventDurationMinutes: Int = 60 // applied to a new event's end time 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 { enum CodingKeys: String, CodingKey {
case defaultView = "default_view" case defaultView = "default_view"
@@ -35,21 +38,24 @@ struct AppSettings: Codable {
case monthDividerColor = "month_divider_color" case monthDividerColor = "month_divider_color"
case monthLabelColor = "month_label_color" case monthLabelColor = "month_label_color"
case textColor = "text_color" case textColor = "text_color"
case backgroundColor = "background_color" case backgroundColor = "bg_color"
case lineColor = "line_color" case lineColor = "line_color"
case privateEventVisibility = "private_event_visibility" case privateEventVisibility = "private_event_visibility"
case groupVisibleCalendarId = "group_visible_calendar_id" case groupVisibleCalendarId = "group_visible_calendar_id"
case defaultReminderMinutes = "default_reminder_minutes" case defaultReminderMinutes = "default_reminder_minutes"
case defaultEventDurationMinutes = "default_event_duration_minutes" case defaultEventDurationMinutes = "default_event_duration_minutes"
case cacheMonths = "cache_months"
case monthViewPaged = "month_view_paged"
case syncFlags = "sync_flags"
} }
init() {} init() {}
/// Resilient decoding: the server only stores a subset of these fields /// Resilient decoding: a server may omit some keys. Using `decodeIfPresent`
/// (e.g. it has no `text_color`/`background_color`/`line_color`, which are /// with the property defaults means a missing key no longer aborts the whole
/// iOS-only). Using `decodeIfPresent` with the property defaults means a /// decode otherwise the entire settings sync silently breaks. (Note:
/// missing key no longer aborts the whole decode otherwise the entire /// `background_color` was previously mis-mapped; the server column is
/// settings sync silently breaks. /// `bg_color`, now corrected above.)
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self) let c = try decoder.container(keyedBy: CodingKeys.self)
let d = AppSettings() let d = AppSettings()
@@ -72,6 +78,9 @@ struct AppSettings: Codable {
groupVisibleCalendarId = try c.decodeIfPresent(Int.self, forKey: .groupVisibleCalendarId) groupVisibleCalendarId = try c.decodeIfPresent(Int.self, forKey: .groupVisibleCalendarId)
defaultReminderMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultReminderMinutes) defaultReminderMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultReminderMinutes)
defaultEventDurationMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultEventDurationMinutes) ?? d.defaultEventDurationMinutes 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)
} }
} }

View File

@@ -579,7 +579,7 @@ class CalendarStore {
todayColorHex: defaults.string(forKey: "todayColor") ?? "#4285f4", todayColorHex: defaults.string(forKey: "todayColor") ?? "#4285f4",
textColorHex: defaults.string(forKey: "textColor") ?? "#FFFFFF", textColorHex: defaults.string(forKey: "textColor") ?? "#FFFFFF",
backgroundColorHex: defaults.string(forKey: "backgroundColor") ?? "#000000", backgroundColorHex: defaults.string(forKey: "backgroundColor") ?? "#000000",
lineColorHex: defaults.string(forKey: "lineColor") ?? "#3A3A3C", lineColorHex: defaults.string(forKey: "lineColor") ?? "#3A3A52",
primaryColorHex: defaults.string(forKey: "primaryColor") ?? "#4285f4", primaryColorHex: defaults.string(forKey: "primaryColor") ?? "#4285f4",
accentColorHex: defaults.string(forKey: "accentColor") ?? "#ea4335", accentColorHex: defaults.string(forKey: "accentColor") ?? "#ea4335",
language: defaults.string(forKey: "appLanguage") ?? "system" language: defaults.string(forKey: "appLanguage") ?? "system"

View File

@@ -81,6 +81,13 @@ private let strings: [String: [String: String]] = [
"settings.sync": "Einstellungen synchronisieren", "settings.sync": "Einstellungen synchronisieren",
"settings.sync.desc": "Darstellung mit dem Server abgleichen", "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.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.header": "Vorladen",
"settings.cache.title": "Vorladen", "settings.cache.title": "Vorladen",
@@ -440,6 +447,13 @@ private let strings: [String: [String: String]] = [
"settings.sync": "Sync settings", "settings.sync": "Sync settings",
"settings.sync.desc": "Keep appearance in sync with the server", "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.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.header": "Preloading",
"settings.cache.title": "Preloading", "settings.cache.title": "Preloading",

View File

@@ -6,111 +6,185 @@ extension Notification.Name {
static let settingsDidChange = Notification.Name("settingsDidChange") static let settingsDidChange = Notification.Name("settingsDidChange")
} }
/// Two-way synchronisation of appearance/behaviour settings between the app and /// Per-setting cross-device synchronisation. The server is the sole authority for
/// the Calendarr server. The server is treated as the source of truth on pull; /// WHICH settings sync (its `sync_flags` map); this client keeps a cached copy of
/// local edits are pushed immediately so the server then holds the newest value. /// that map and only sends/applies the keys whose flag is on. See
/// backend/SETTINGS_SYNC.md for the shared contract.
/// ///
/// Two groups: /// - Pull: for every syncable key whose flag is on, the server value wins.
/// - **optional** (colors, contrasts, hour height) only sync when the user has /// - Push (debounced, read-modify-write): start from the server snapshot,
/// enabled the `settingsSync` toggle. /// overwrite only the on-flagged keys with the local value, PUT.
/// - **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 { 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 { static let syncableKeys: [String] = Array(keyToDefaults.keys)
// optional group
static let primaryColor = "primaryColor" /// Fallback flags used only until the first server pull populates the real map
static let accentColor = "accentColor" /// (mirrors the server's DEFAULT_SYNC for the iOS-managed keys).
static let todayColor = "todayColor" static let defaultSync: [String: Bool] = [
static let textColor = "textColor" "default_view": true, "week_start_day": true, "dim_past_events": true,
static let backgroundColor = "backgroundColor" "hour_height": true, "default_event_duration_minutes": true,
static let lineColor = "lineColor" "default_reminder_minutes": true, "primary_color": true, "accent_color": true,
static let monthDividerColor = "monthDividerColor" "today_color": true, "text_color": true, "bg_color": true, "line_color": true,
static let monthLabelColor = "monthLabelColor" "month_divider_color": true, "month_label_color": true,
static let textContrast = "textContrast" "cache_months": false, "month_view_paged": false,
static let lineContrast = "lineContrast" ]
static let hourHeight = "hourHeight"
// always group // MARK: Flag storage (UserDefaults JSON, mirrors the server's resolved map)
static let defaultView = "defaultView"
static let weekStartDay = "weekStartDay" private static let flagsKey = "settingsSyncFlags"
static let dimPastEvents = "dimPastEvents"
static let defaultReminder = "defaultReminderMinutes" // Int, -1 = off /// Effective flags: stored overrides on top of the fallback defaults.
static let defaultEventDuration = "defaultEventDurationMinutes" // Int minutes static func flags() -> [String: Bool] {
// master switch var result = defaultSync
static let enabled = "settingsSync" 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 writeFlags(_ f: [String: Bool]) {
if let data = try? JSONEncoder().encode(f) {
private static func int(_ key: String, _ fallback: Int) -> Int { UserDefaults.standard.set(data, forKey: flagsKey)
let v = UserDefaults.standard.object(forKey: key) as? Int }
return v ?? fallback
} }
/// 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 { private static func str(_ key: String, _ fallback: String) -> String {
UserDefaults.standard.string(forKey: key) ?? fallback 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 { static func currentSettings() -> AppSettings {
var s = AppSettings() var s = AppSettings()
s.primaryColor = str(Key.primaryColor, "#4285f4") s.primaryColor = str("primaryColor", "#4285f4")
s.accentColor = str(Key.accentColor, "#ea4335") s.accentColor = str("accentColor", "#ea4335")
s.todayColor = str(Key.todayColor, "#4285f4") s.todayColor = str("todayColor", "#4285f4")
s.textColor = str(Key.textColor, "#FFFFFF") s.textColor = str("textColor", "#FFFFFF")
s.backgroundColor = str(Key.backgroundColor, "#000000") s.backgroundColor = str("backgroundColor", "#000000")
s.lineColor = str(Key.lineColor, "#3A3A3C") s.lineColor = str("lineColor", "#3A3A52")
s.monthDividerColor = str(Key.monthDividerColor, "#7090c0") s.monthDividerColor = str("monthDividerColor", "#7090c0")
s.monthLabelColor = str(Key.monthLabelColor, "#7090c0") s.monthLabelColor = str("monthLabelColor", "#7090c0")
s.textContrast = int(Key.textContrast, 3) s.hourHeight = int("hourHeight", 60)
s.lineContrast = int(Key.lineContrast, 3) s.defaultView = str("defaultView", "month")
s.hourHeight = int(Key.hourHeight, 60) s.weekStartDay = str("weekStartDay", "monday")
s.defaultView = str(Key.defaultView, "month") s.dimPastEvents = UserDefaults.standard.bool(forKey: "dimPastEvents")
s.weekStartDay = str(Key.weekStartDay, "monday") s.cacheMonths = int("cacheMonths", 3)
s.dimPastEvents = UserDefaults.standard.bool(forKey: Key.dimPastEvents) s.monthViewPaged = UserDefaults.standard.bool(forKey: "monthViewPaged")
let rem = int(Key.defaultReminder, -1) let rem = int("defaultReminderMinutes", -1)
s.defaultReminderMinutes = rem < 0 ? nil : rem s.defaultReminderMinutes = rem < 0 ? nil : rem
s.defaultEventDurationMinutes = int(Key.defaultEventDuration, 60) s.defaultEventDurationMinutes = int("defaultEventDurationMinutes", 60)
return s 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 /// Write one synced field from a server snapshot into local UserDefaults.
/// `includeOptional` is true. private static func applyField(_ key: String, from s: AppSettings) {
static func apply(_ s: AppSettings, includeOptional: Bool) {
let d = UserDefaults.standard let d = UserDefaults.standard
// always group switch key {
d.set(s.defaultView, forKey: Key.defaultView) case "default_view": d.set(s.defaultView, forKey: "defaultView")
d.set(s.weekStartDay, forKey: Key.weekStartDay) case "week_start_day": d.set(s.weekStartDay, forKey: "weekStartDay")
d.set(s.dimPastEvents, forKey: Key.dimPastEvents) case "dim_past_events": d.set(s.dimPastEvents, forKey: "dimPastEvents")
d.set(s.defaultReminderMinutes ?? -1, forKey: Key.defaultReminder) case "hour_height": d.set(s.hourHeight, forKey: "hourHeight")
d.set(s.defaultEventDurationMinutes, forKey: Key.defaultEventDuration) case "default_event_duration_minutes": d.set(s.defaultEventDurationMinutes, forKey: "defaultEventDurationMinutes")
guard includeOptional else { return } case "default_reminder_minutes": d.set(s.defaultReminderMinutes ?? -1, forKey: "defaultReminderMinutes")
// NOTE: textColor / backgroundColor / lineColor are intentionally NOT case "primary_color": d.set(s.primaryColor, forKey: "primaryColor")
// synced the server has no columns for them (iOS-only). Writing the case "accent_color": d.set(s.accentColor, forKey: "accentColor")
// resilient-decoded defaults here would wipe the user's local choices. case "today_color": d.set(s.todayColor, forKey: "todayColor")
d.set(s.primaryColor, forKey: Key.primaryColor) case "text_color": d.set(s.textColor, forKey: "textColor")
d.set(s.accentColor, forKey: Key.accentColor) case "bg_color": d.set(s.backgroundColor, forKey: "backgroundColor")
d.set(s.todayColor, forKey: Key.todayColor) case "line_color": d.set(s.lineColor, forKey: "lineColor")
d.set(s.monthDividerColor, forKey: Key.monthDividerColor) case "month_divider_color": d.set(s.monthDividerColor, forKey: "monthDividerColor")
d.set(s.monthLabelColor, forKey: Key.monthLabelColor) case "month_label_color": d.set(s.monthLabelColor, forKey: "monthLabelColor")
d.set(s.textContrast, forKey: Key.textContrast) case "cache_months": d.set(s.cacheMonths, forKey: "cacheMonths")
d.set(s.lineContrast, forKey: Key.lineContrast) case "month_view_paged": d.set(s.monthViewPaged, forKey: "monthViewPaged")
d.set(s.hourHeight, forKey: Key.hourHeight) default: break
}
} }
// MARK: Pull // 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 { static func pull(api: CalendarrAPI) async {
guard let server = try? await api.getSettings() else { return } 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 { await MainActor.run {
NotificationCenter.default.post(name: .settingsDidChange, object: nil) 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, /// Read-modify-write: start from the server's current settings so unsynced
/// when the optional group is NOT being synced, the server's colours stay /// fields stay intact, overwrite only the on-flagged keys with local values,
/// intact. Overwrite the trio always, the optional group only if enabled. /// and send the current flag map so the account-wide config stays in sync.
private static func performPush(api: CalendarrAPI) async { private static func performPush(api: CalendarrAPI) async {
guard var merged = try? await api.getSettings() else { return } guard var merged = try? await api.getSettings() else { return }
let local = currentSettings() let local = currentSettings()
// always group let f = flags()
merged.defaultView = local.defaultView for key in syncableKeys where f[key] == true {
merged.weekStartDay = local.weekStartDay copyField(key, from: local, into: &merged)
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
} }
merged.syncFlags = f
try? await api.updateSettings(merged) try? await api.updateSettings(merged)
} }
} }

View File

@@ -8,7 +8,7 @@ struct DayView: View {
@AppStorage("appLanguage") private var appLang = "system" @AppStorage("appLanguage") private var appLang = "system"
@AppStorage("todayColor") private var todayHex = "#4285f4" @AppStorage("todayColor") private var todayHex = "#4285f4"
@AppStorage("textColor") private var textHex = "#FFFFFF" @AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A3C" @AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("textContrast") private var textContrast = 3 @AppStorage("textContrast") private var textContrast = 3
@AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout @AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout
@@ -137,7 +137,7 @@ private struct DayHourSlot: View {
let language: String let language: String
let onCreateEvent: (Date) -> Void let onCreateEvent: (Date) -> Void
@AppStorage("lineColor") private var lineHex = "#3A3A3C" @AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("lineContrast") private var lineContrast = 3 @AppStorage("lineContrast") private var lineContrast = 3
private var date: Date { private var date: Date {

View File

@@ -27,7 +27,7 @@ struct MonthView: View {
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0" @AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
@AppStorage("monthLabelColor") private var labelHex = "#7090c0" @AppStorage("monthLabelColor") private var labelHex = "#7090c0"
@AppStorage("textColor") private var textHex = "#FFFFFF" @AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A3C" @AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("textContrast") private var textContrast = 3 @AppStorage("textContrast") private var textContrast = 3
@AppStorage("monthViewPaged") private var monthPaged = false @AppStorage("monthViewPaged") private var monthPaged = false

View File

@@ -10,7 +10,7 @@ struct WeekView: View {
@AppStorage("appLanguage") private var appLang = "system" @AppStorage("appLanguage") private var appLang = "system"
@AppStorage("todayColor") private var todayHex = "#4285f4" @AppStorage("todayColor") private var todayHex = "#4285f4"
@AppStorage("textColor") private var textHex = "#FFFFFF" @AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A3C" @AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("textContrast") private var textContrast = 3 @AppStorage("textContrast") private var textContrast = 3
@AppStorage("lineContrast") private var lineContrast = 3 @AppStorage("lineContrast") private var lineContrast = 3
@AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout @AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout
@@ -213,7 +213,7 @@ struct HourSlot: View {
let onShowMonth: (Date) -> Void let onShowMonth: (Date) -> Void
let onShowDay: (Date) -> Void let onShowDay: (Date) -> Void
@AppStorage("lineColor") private var lineHex = "#3A3A3C" @AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("lineContrast") private var lineContrast = 3 @AppStorage("lineContrast") private var lineContrast = 3
private var date: Date { private var date: Date {

View File

@@ -3,31 +3,30 @@ import SwiftUI
struct SettingsView: View { struct SettingsView: View {
let api: CalendarrAPI let api: CalendarrAPI
@AppStorage("liquidGlass") private var liquidGlass = false @AppStorage("liquidGlass") private var liquidGlass = false
@AppStorage("settingsSync") private var settingsSync = false
@AppStorage("cacheMonths") private var cacheMonths = 3 @AppStorage("cacheMonths") private var cacheMonths = 3
@AppStorage("appLanguage") private var appLang = "system" @AppStorage("appLanguage") private var appLang = "system"
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0" @AppStorage("monthDividerColor") private var dividerHex = "#7090C0"
@AppStorage("monthLabelColor") private var labelHex = "#7090c0" @AppStorage("monthLabelColor") private var labelHex = "#7090C0"
@AppStorage("todayColor") private var todayHex = "#4285f4" @AppStorage("todayColor") private var todayHex = "#4285F4"
@AppStorage("textColor") private var textHex = "#FFFFFF" @AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("backgroundColor") private var bgHex = "#000000" @AppStorage("backgroundColor") private var bgHex = "#000000"
@AppStorage("lineColor") private var lineHex = "#3A3A3C" @AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("primaryColor") private var primaryHex = "#4285f4" @AppStorage("primaryColor") private var primaryHex = "#4285F4"
@AppStorage("accentColor") private var accentHex = "#ea4335" @AppStorage("accentColor") private var accentHex = "#EA4335"
// Previously server-only; now AppStorage-backed so they persist and the // iOS-only opacity controls (drive secondary text / grid-line opacity in the
// calendar views actually apply them. // calendar views); kept device-local, not part of the cross-device sync.
@AppStorage("textContrast") private var textContrast = 3 @AppStorage("textContrast") private var textContrast = 3
@AppStorage("lineContrast") private var lineContrast = 3 @AppStorage("lineContrast") private var lineContrast = 3
@AppStorage("hourHeight") private var hourHeight = 60 @AppStorage("hourHeight") private var hourHeight = 60
@AppStorage("defaultView") private var defaultView = "month" @AppStorage("defaultView") private var defaultView = "month"
@AppStorage("weekStartDay") private var weekStartDay = "monday" @AppStorage("weekStartDay") private var weekStartDay = "monday"
@AppStorage("dimPastEvents") private var dimPastEvents = false @AppStorage("dimPastEvents") private var dimPastEvents = false
// Device-local: month view as horizontal paged (swipe) vs. scroll feed.
@AppStorage("monthViewPaged") private var monthViewPaged = false @AppStorage("monthViewPaged") private var monthViewPaged = false
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1 @AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
@AppStorage("defaultEventDurationMinutes") private var defaultEventDurationMinutes = 60 @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 displayName = ""
@State private var loginName = "" @State private var loginName = ""
@State private var email = "" @State private var email = ""
@@ -37,52 +36,127 @@ struct SettingsView: View {
@State private var directoryHidden = false @State private var directoryHidden = false
@State private var profileMsg = "" @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 { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
globalSyncSection
profilSection profilSection
privatsphaereSection privatsphaereSection
termindauerSection
benachrichtigungenSection
geteilterKalenderSection geteilterKalenderSection
liquidGlassSection termineSection
cacheSection
spracheSection
farbenSection
schriftSection
linienSection
ansichtSection ansichtSection
stundenSection farbenSection
cacheSection
geraetSection
} }
.navigationTitle(L10n.t("settings.title", appLang)) .navigationTitle(L10n.t("settings.title", appLang))
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
} }
// Reflect the latest server values when opening the screen. // Reflect the latest server values (and flag map) when opening the screen.
.task { await SettingsSync.pull(api: api) } .task { await SettingsSync.pull(api: api); syncFlags = SettingsSync.flags() }
.task { await loadProfile() } .task { await loadProfile() }
// Appearance changes update widgets live; synced values are also pushed // Value edits update widgets live; a synced value is also pushed (debounced).
// to the server (debounced). `push` itself decides what actually gets // `push` decides what actually gets sent based on each key's flag.
// 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: primaryHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: accentHex) { _, _ 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: todayHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: textHex) { _, _ 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: bgHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: lineHex) { _, _ 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: dividerHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: labelHex) { _, _ in SettingsSync.push(api: api) } .onChange(of: labelHex) { _, _ in WidgetStore.republishAppearanceOnly(); 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: hourHeight) { _, _ in SettingsSync.push(api: api) }
.onChange(of: defaultView) { _, _ in SettingsSync.push(api: api) } .onChange(of: defaultView) { _, _ in SettingsSync.push(api: api) }
.onChange(of: weekStartDay) { _, _ in SettingsSync.push(api: api) } .onChange(of: weekStartDay) { _, _ in SettingsSync.push(api: api) }
.onChange(of: dimPastEvents) { _, _ 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() } .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<String>) -> Binding<Color> {
Binding(get: { Color(hex: hex.wrappedValue) }, set: { hex.wrappedValue = $0.toHex() })
}
@ViewBuilder
private func colorRow(_ syncKey: String, _ defaultsKey: String, _ label: String, _ hex: Binding<String>) -> 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<Bool> {
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 { var profilSection: some View {
Section(L10n.t("settings.nav.profile", appLang)) { Section(L10n.t("settings.nav.profile", appLang)) {
@@ -119,28 +193,7 @@ struct SettingsView: View {
} }
} }
// MARK: Benachrichtigungen // MARK: Privatsphäre (account not synced)
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
var privatsphaereSection: some View { var privatsphaereSection: some View {
Section { Section {
@@ -158,22 +211,7 @@ struct SettingsView: View {
} }
} }
// MARK: Standardtermindauer // MARK: Geteilter Kalender (account not synced)
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
var geteilterKalenderSection: some View { var geteilterKalenderSection: some View {
Section { 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 { private func loadProfile() async {
if let p = try? await api.getProfile() { if let p = try? await api.getProfile() {
displayName = p.displayName ?? p.username displayName = p.displayName ?? p.username
@@ -223,252 +425,4 @@ struct SettingsView: View {
profileMsg = error.localizedDescription 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<Color> {
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<T: Hashable & Equatable>: 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)
}
}
}
} }