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:
@@ -6,111 +6,185 @@ extension Notification.Name {
|
||||
static let settingsDidChange = Notification.Name("settingsDidChange")
|
||||
}
|
||||
|
||||
/// Two-way synchronisation of appearance/behaviour settings between the app and
|
||||
/// the Calendarr server. The server is treated as the source of truth on pull;
|
||||
/// local edits are pushed immediately so the server then holds the newest value.
|
||||
/// Per-setting cross-device synchronisation. The server is the sole authority for
|
||||
/// WHICH settings sync (its `sync_flags` map); this client keeps a cached copy of
|
||||
/// that map and only sends/applies the keys whose flag is on. See
|
||||
/// backend/SETTINGS_SYNC.md for the shared contract.
|
||||
///
|
||||
/// Two groups:
|
||||
/// - **optional** (colors, contrasts, hour height) only sync when the user has
|
||||
/// enabled the `settingsSync` toggle.
|
||||
/// - **always** (default view, week start, dim past events) sync regardless of
|
||||
/// the toggle, because they describe how the user expects the calendar to be
|
||||
/// computed/presented everywhere.
|
||||
/// - Pull: for every syncable key whose flag is on, the server value wins.
|
||||
/// - Push (debounced, read-modify-write): start from the server snapshot,
|
||||
/// overwrite only the on-flagged keys with the local value, PUT.
|
||||
enum SettingsSync {
|
||||
|
||||
// MARK: – UserDefaults keys
|
||||
/// Server keys this iOS client can sync, mapped to their UserDefaults key.
|
||||
/// Intentionally excluded: `language` (iOS "system" has no server value),
|
||||
/// `share_calendar_icon` (no iOS UI), text/line contrast (iOS-only opacity
|
||||
/// controls kept device-local), `liquid_glass` (iOS-only).
|
||||
static let keyToDefaults: [String: String] = [
|
||||
"default_view": "defaultView",
|
||||
"week_start_day": "weekStartDay",
|
||||
"dim_past_events": "dimPastEvents",
|
||||
"hour_height": "hourHeight",
|
||||
"default_event_duration_minutes": "defaultEventDurationMinutes",
|
||||
"default_reminder_minutes": "defaultReminderMinutes",
|
||||
"primary_color": "primaryColor",
|
||||
"accent_color": "accentColor",
|
||||
"today_color": "todayColor",
|
||||
"text_color": "textColor",
|
||||
"bg_color": "backgroundColor",
|
||||
"line_color": "lineColor",
|
||||
"month_divider_color": "monthDividerColor",
|
||||
"month_label_color": "monthLabelColor",
|
||||
"cache_months": "cacheMonths",
|
||||
"month_view_paged": "monthViewPaged",
|
||||
]
|
||||
|
||||
enum Key {
|
||||
// optional group
|
||||
static let primaryColor = "primaryColor"
|
||||
static let accentColor = "accentColor"
|
||||
static let todayColor = "todayColor"
|
||||
static let textColor = "textColor"
|
||||
static let backgroundColor = "backgroundColor"
|
||||
static let lineColor = "lineColor"
|
||||
static let monthDividerColor = "monthDividerColor"
|
||||
static let monthLabelColor = "monthLabelColor"
|
||||
static let textContrast = "textContrast"
|
||||
static let lineContrast = "lineContrast"
|
||||
static let hourHeight = "hourHeight"
|
||||
// always group
|
||||
static let defaultView = "defaultView"
|
||||
static let weekStartDay = "weekStartDay"
|
||||
static let dimPastEvents = "dimPastEvents"
|
||||
static let defaultReminder = "defaultReminderMinutes" // Int, -1 = off
|
||||
static let defaultEventDuration = "defaultEventDurationMinutes" // Int minutes
|
||||
// master switch
|
||||
static let enabled = "settingsSync"
|
||||
static let syncableKeys: [String] = Array(keyToDefaults.keys)
|
||||
|
||||
/// Fallback flags used only until the first server pull populates the real map
|
||||
/// (mirrors the server's DEFAULT_SYNC for the iOS-managed keys).
|
||||
static let defaultSync: [String: Bool] = [
|
||||
"default_view": true, "week_start_day": true, "dim_past_events": true,
|
||||
"hour_height": true, "default_event_duration_minutes": true,
|
||||
"default_reminder_minutes": true, "primary_color": true, "accent_color": true,
|
||||
"today_color": true, "text_color": true, "bg_color": true, "line_color": true,
|
||||
"month_divider_color": true, "month_label_color": true,
|
||||
"cache_months": false, "month_view_paged": false,
|
||||
]
|
||||
|
||||
// MARK: – Flag storage (UserDefaults JSON, mirrors the server's resolved map)
|
||||
|
||||
private static let flagsKey = "settingsSyncFlags"
|
||||
|
||||
/// Effective flags: stored overrides on top of the fallback defaults.
|
||||
static func flags() -> [String: Bool] {
|
||||
var result = defaultSync
|
||||
if let data = UserDefaults.standard.data(forKey: flagsKey),
|
||||
let stored = try? JSONDecoder().decode([String: Bool].self, from: data) {
|
||||
for (k, v) in stored { result[k] = v }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
static var isEnabled: Bool { UserDefaults.standard.bool(forKey: Key.enabled) }
|
||||
static func isSynced(_ key: String) -> Bool { flags()[key] ?? false }
|
||||
|
||||
// MARK: – Defaults (mirror the historical hard-coded values)
|
||||
|
||||
private static func int(_ key: String, _ fallback: Int) -> Int {
|
||||
let v = UserDefaults.standard.object(forKey: key) as? Int
|
||||
return v ?? fallback
|
||||
private static func writeFlags(_ f: [String: Bool]) {
|
||||
if let data = try? JSONEncoder().encode(f) {
|
||||
UserDefaults.standard.set(data, forKey: flagsKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the cached flags with the server's resolved map (known keys only).
|
||||
static func storeServerFlags(_ server: [String: Bool]?) {
|
||||
guard let server else { return }
|
||||
var f = flags()
|
||||
for key in syncableKeys { if let v = server[key] { f[key] = v } }
|
||||
writeFlags(f)
|
||||
}
|
||||
|
||||
/// Toggle one setting's sync flag. Turning it on pushes this device's current
|
||||
/// value up (it becomes the shared value); turning off keeps the value local.
|
||||
static func setSynced(_ key: String, _ on: Bool, api: CalendarrAPI) {
|
||||
var f = flags(); f[key] = on; writeFlags(f)
|
||||
push(api: api)
|
||||
}
|
||||
|
||||
/// The global "sync everything" switch.
|
||||
static func setAllSynced(_ on: Bool, api: CalendarrAPI) {
|
||||
var f = flags(); for key in syncableKeys { f[key] = on }; writeFlags(f)
|
||||
push(api: api)
|
||||
}
|
||||
|
||||
// MARK: – Local ⇄ AppSettings field mapping
|
||||
|
||||
private static func str(_ key: String, _ fallback: String) -> String {
|
||||
UserDefaults.standard.string(forKey: key) ?? fallback
|
||||
}
|
||||
private static func int(_ key: String, _ fallback: Int) -> Int {
|
||||
UserDefaults.standard.object(forKey: key) as? Int ?? fallback
|
||||
}
|
||||
|
||||
// MARK: – Build AppSettings from local UserDefaults
|
||||
|
||||
/// Build an AppSettings snapshot from local UserDefaults.
|
||||
static func currentSettings() -> AppSettings {
|
||||
var s = AppSettings()
|
||||
s.primaryColor = str(Key.primaryColor, "#4285f4")
|
||||
s.accentColor = str(Key.accentColor, "#ea4335")
|
||||
s.todayColor = str(Key.todayColor, "#4285f4")
|
||||
s.textColor = str(Key.textColor, "#FFFFFF")
|
||||
s.backgroundColor = str(Key.backgroundColor, "#000000")
|
||||
s.lineColor = str(Key.lineColor, "#3A3A3C")
|
||||
s.monthDividerColor = str(Key.monthDividerColor, "#7090c0")
|
||||
s.monthLabelColor = str(Key.monthLabelColor, "#7090c0")
|
||||
s.textContrast = int(Key.textContrast, 3)
|
||||
s.lineContrast = int(Key.lineContrast, 3)
|
||||
s.hourHeight = int(Key.hourHeight, 60)
|
||||
s.defaultView = str(Key.defaultView, "month")
|
||||
s.weekStartDay = str(Key.weekStartDay, "monday")
|
||||
s.dimPastEvents = UserDefaults.standard.bool(forKey: Key.dimPastEvents)
|
||||
let rem = int(Key.defaultReminder, -1)
|
||||
s.primaryColor = str("primaryColor", "#4285f4")
|
||||
s.accentColor = str("accentColor", "#ea4335")
|
||||
s.todayColor = str("todayColor", "#4285f4")
|
||||
s.textColor = str("textColor", "#FFFFFF")
|
||||
s.backgroundColor = str("backgroundColor", "#000000")
|
||||
s.lineColor = str("lineColor", "#3A3A52")
|
||||
s.monthDividerColor = str("monthDividerColor", "#7090c0")
|
||||
s.monthLabelColor = str("monthLabelColor", "#7090c0")
|
||||
s.hourHeight = int("hourHeight", 60)
|
||||
s.defaultView = str("defaultView", "month")
|
||||
s.weekStartDay = str("weekStartDay", "monday")
|
||||
s.dimPastEvents = UserDefaults.standard.bool(forKey: "dimPastEvents")
|
||||
s.cacheMonths = int("cacheMonths", 3)
|
||||
s.monthViewPaged = UserDefaults.standard.bool(forKey: "monthViewPaged")
|
||||
let rem = int("defaultReminderMinutes", -1)
|
||||
s.defaultReminderMinutes = rem < 0 ? nil : rem
|
||||
s.defaultEventDurationMinutes = int(Key.defaultEventDuration, 60)
|
||||
s.defaultEventDurationMinutes = int("defaultEventDurationMinutes", 60)
|
||||
return s
|
||||
}
|
||||
|
||||
// MARK: – Apply a server snapshot to local UserDefaults
|
||||
/// Copy one synced field from a source snapshot into a destination snapshot.
|
||||
private static func copyField(_ key: String, from src: AppSettings, into dst: inout AppSettings) {
|
||||
switch key {
|
||||
case "default_view": dst.defaultView = src.defaultView
|
||||
case "week_start_day": dst.weekStartDay = src.weekStartDay
|
||||
case "dim_past_events": dst.dimPastEvents = src.dimPastEvents
|
||||
case "hour_height": dst.hourHeight = src.hourHeight
|
||||
case "default_event_duration_minutes": dst.defaultEventDurationMinutes = src.defaultEventDurationMinutes
|
||||
case "default_reminder_minutes": dst.defaultReminderMinutes = src.defaultReminderMinutes
|
||||
case "primary_color": dst.primaryColor = src.primaryColor
|
||||
case "accent_color": dst.accentColor = src.accentColor
|
||||
case "today_color": dst.todayColor = src.todayColor
|
||||
case "text_color": dst.textColor = src.textColor
|
||||
case "bg_color": dst.backgroundColor = src.backgroundColor
|
||||
case "line_color": dst.lineColor = src.lineColor
|
||||
case "month_divider_color": dst.monthDividerColor = src.monthDividerColor
|
||||
case "month_label_color": dst.monthLabelColor = src.monthLabelColor
|
||||
case "cache_months": dst.cacheMonths = src.cacheMonths
|
||||
case "month_view_paged": dst.monthViewPaged = src.monthViewPaged
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
/// Always writes the "always" trio. Writes the optional group only when
|
||||
/// `includeOptional` is true.
|
||||
static func apply(_ s: AppSettings, includeOptional: Bool) {
|
||||
/// Write one synced field from a server snapshot into local UserDefaults.
|
||||
private static func applyField(_ key: String, from s: AppSettings) {
|
||||
let d = UserDefaults.standard
|
||||
// always group
|
||||
d.set(s.defaultView, forKey: Key.defaultView)
|
||||
d.set(s.weekStartDay, forKey: Key.weekStartDay)
|
||||
d.set(s.dimPastEvents, forKey: Key.dimPastEvents)
|
||||
d.set(s.defaultReminderMinutes ?? -1, forKey: Key.defaultReminder)
|
||||
d.set(s.defaultEventDurationMinutes, forKey: Key.defaultEventDuration)
|
||||
guard includeOptional else { return }
|
||||
// NOTE: textColor / backgroundColor / lineColor are intentionally NOT
|
||||
// synced – the server has no columns for them (iOS-only). Writing the
|
||||
// resilient-decoded defaults here would wipe the user's local choices.
|
||||
d.set(s.primaryColor, forKey: Key.primaryColor)
|
||||
d.set(s.accentColor, forKey: Key.accentColor)
|
||||
d.set(s.todayColor, forKey: Key.todayColor)
|
||||
d.set(s.monthDividerColor, forKey: Key.monthDividerColor)
|
||||
d.set(s.monthLabelColor, forKey: Key.monthLabelColor)
|
||||
d.set(s.textContrast, forKey: Key.textContrast)
|
||||
d.set(s.lineContrast, forKey: Key.lineContrast)
|
||||
d.set(s.hourHeight, forKey: Key.hourHeight)
|
||||
switch key {
|
||||
case "default_view": d.set(s.defaultView, forKey: "defaultView")
|
||||
case "week_start_day": d.set(s.weekStartDay, forKey: "weekStartDay")
|
||||
case "dim_past_events": d.set(s.dimPastEvents, forKey: "dimPastEvents")
|
||||
case "hour_height": d.set(s.hourHeight, forKey: "hourHeight")
|
||||
case "default_event_duration_minutes": d.set(s.defaultEventDurationMinutes, forKey: "defaultEventDurationMinutes")
|
||||
case "default_reminder_minutes": d.set(s.defaultReminderMinutes ?? -1, forKey: "defaultReminderMinutes")
|
||||
case "primary_color": d.set(s.primaryColor, forKey: "primaryColor")
|
||||
case "accent_color": d.set(s.accentColor, forKey: "accentColor")
|
||||
case "today_color": d.set(s.todayColor, forKey: "todayColor")
|
||||
case "text_color": d.set(s.textColor, forKey: "textColor")
|
||||
case "bg_color": d.set(s.backgroundColor, forKey: "backgroundColor")
|
||||
case "line_color": d.set(s.lineColor, forKey: "lineColor")
|
||||
case "month_divider_color": d.set(s.monthDividerColor, forKey: "monthDividerColor")
|
||||
case "month_label_color": d.set(s.monthLabelColor, forKey: "monthLabelColor")
|
||||
case "cache_months": d.set(s.cacheMonths, forKey: "cacheMonths")
|
||||
case "month_view_paged": d.set(s.monthViewPaged, forKey: "monthViewPaged")
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Pull
|
||||
|
||||
/// Fetch the server's settings and apply them locally (server wins).
|
||||
/// Fetch the server's settings, refresh the flag cache, and apply the value of
|
||||
/// every on-flagged key locally (server wins).
|
||||
static func pull(api: CalendarrAPI) async {
|
||||
guard let server = try? await api.getSettings() else { return }
|
||||
apply(server, includeOptional: isEnabled)
|
||||
storeServerFlags(server.syncFlags)
|
||||
let f = flags()
|
||||
for key in syncableKeys where f[key] == true {
|
||||
applyField(key, from: server)
|
||||
}
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .settingsDidChange, object: nil)
|
||||
}
|
||||
@@ -131,31 +205,17 @@ enum SettingsSync {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-modify-write: start from the server's current settings so that,
|
||||
/// when the optional group is NOT being synced, the server's colours stay
|
||||
/// intact. Overwrite the trio always, the optional group only if enabled.
|
||||
/// Read-modify-write: start from the server's current settings so unsynced
|
||||
/// fields stay intact, overwrite only the on-flagged keys with local values,
|
||||
/// and send the current flag map so the account-wide config stays in sync.
|
||||
private static func performPush(api: CalendarrAPI) async {
|
||||
guard var merged = try? await api.getSettings() else { return }
|
||||
let local = currentSettings()
|
||||
// always group
|
||||
merged.defaultView = local.defaultView
|
||||
merged.weekStartDay = local.weekStartDay
|
||||
merged.dimPastEvents = local.dimPastEvents
|
||||
merged.defaultReminderMinutes = local.defaultReminderMinutes
|
||||
merged.defaultEventDurationMinutes = local.defaultEventDurationMinutes
|
||||
if isEnabled {
|
||||
merged.primaryColor = local.primaryColor
|
||||
merged.accentColor = local.accentColor
|
||||
merged.todayColor = local.todayColor
|
||||
merged.textColor = local.textColor
|
||||
merged.backgroundColor = local.backgroundColor
|
||||
merged.lineColor = local.lineColor
|
||||
merged.monthDividerColor = local.monthDividerColor
|
||||
merged.monthLabelColor = local.monthLabelColor
|
||||
merged.textContrast = local.textContrast
|
||||
merged.lineContrast = local.lineContrast
|
||||
merged.hourHeight = local.hourHeight
|
||||
let f = flags()
|
||||
for key in syncableKeys where f[key] == true {
|
||||
copyField(key, from: local, into: &merged)
|
||||
}
|
||||
merged.syncFlags = f
|
||||
try? await api.updateSettings(merged)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user