Files
Calendarr-IOS/Calendarr iOS/Services/SettingsSync.swift
Scarriffle 9a50f25f57 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>
2026-07-14 21:31:16 +02:00

222 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
extension Notification.Name {
/// Posted after a successful pull applied new settings to UserDefaults, so
/// views holding live state (CalendarHostView store, widgets) can react.
static let settingsDidChange = Notification.Name("settingsDidChange")
}
/// 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.
///
/// - 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 {
/// 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",
]
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 func isSynced(_ key: String) -> Bool { flags()[key] ?? false }
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
}
/// Build an AppSettings snapshot from local UserDefaults.
static func currentSettings() -> AppSettings {
var s = AppSettings()
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("defaultEventDurationMinutes", 60)
return s
}
/// 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
}
}
/// Write one synced field from a server snapshot into local UserDefaults.
private static func applyField(_ key: String, from s: AppSettings) {
let d = UserDefaults.standard
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, 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 }
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)
}
}
// MARK: Push (debounced)
private static var pushTask: Task<Void, Never>?
/// Schedule a debounced push. Repeated calls (e.g. while dragging a colour
/// slider) collapse into a single network write ~1.2 s after the last edit.
static func push(api: CalendarrAPI) {
pushTask?.cancel()
pushTask = Task {
try? await Task.sleep(for: .milliseconds(1200))
if Task.isCancelled { return }
await performPush(api: api)
}
}
/// Read-modify-write: start from the server's current settings so 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()
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)
}
}