chore(repo): iOS-App als ios/ ins Hauptrepo uebernehmen

Calendarr-IOS wird mit vollstaendiger Historie als Unterverzeichnis
ios/ eingebunden. Damit liegen Server, Web, Android und iOS in einem
gemeinsamen Repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-14 09:20:12 +02:00
co-authored by Claude Opus 5
107 changed files with 19373 additions and 0 deletions
@@ -0,0 +1,221 @@
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)
}
}