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:
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user