PlayerPreferences.swift (4892B)
1 import Observation 2 import SwiftUI 3 4 /// Local, per-device player preferences (colour and display name). 5 /// 6 /// Persistence is layered: values are written to `UserDefaults` for fast local 7 /// reads and to `NSUbiquitousKeyValueStore` so they follow the user across 8 /// their devices. On launch we prefer the cloud copy if present, falling back 9 /// to the local copy, then to defaults. External changes (from another 10 /// device) are observed and applied live. 11 /// 12 /// This object is anchored in `RootView`'s `@State` and lives for the app's 13 /// lifetime; no `deinit` / observer teardown is required. 14 @Observable 15 @MainActor 16 final class PlayerPreferences { 17 private enum Keys { 18 static let colorID = "playerColorID" 19 static let name = "playerName" 20 static let isICloudSyncEnabled = "isICloudSyncEnabled" 21 static let notifiesNudges = "notifiesNudges" 22 static let notifiesJoins = "notifiesJoins" 23 static let notifiesPauses = "notifiesPauses" 24 static let notifiesCompletions = "notifiesCompletions" 25 static let notifiesInvitations = "notifiesInvitations" 26 } 27 28 private let local: UserDefaults 29 private let cloud: NSUbiquitousKeyValueStore 30 31 var colorID: String { 32 didSet { write(Keys.colorID, colorID) } 33 } 34 35 var name: String { 36 didSet { 37 hasName = Self.isUsableName(name) 38 write(Keys.name, name) 39 } 40 } 41 42 var hasName: Bool 43 44 /// Debugging switch for comparing on-device responsiveness with CloudKit 45 /// work paused. Stored locally only so disabling sync on one device does 46 /// not silently disable it everywhere. 47 var isICloudSyncEnabled: Bool { 48 didSet { local.set(isICloudSyncEnabled, forKey: Keys.isICloudSyncEnabled) } 49 } 50 51 var color: PlayerColor { 52 get { PlayerColor.color(for: colorID) } 53 set { colorID = newValue.id } 54 } 55 56 /// Notification preferences are per-device, like the system's own 57 /// notification settings, so they are stored locally only. The first three 58 /// gate worker pushes (the device registers the kinds it has muted and the 59 /// worker drops them before APNs); `notifiesInvitations` gates the local 60 /// notification posted when an invite Ping arrives — the invite itself 61 /// still appears in the Invited section either way. 62 var notifiesNudges: Bool { 63 didSet { local.set(notifiesNudges, forKey: Keys.notifiesNudges) } 64 } 65 66 var notifiesJoins: Bool { 67 didSet { local.set(notifiesJoins, forKey: Keys.notifiesJoins) } 68 } 69 70 var notifiesPauses: Bool { 71 didSet { local.set(notifiesPauses, forKey: Keys.notifiesPauses) } 72 } 73 74 var notifiesCompletions: Bool { 75 didSet { local.set(notifiesCompletions, forKey: Keys.notifiesCompletions) } 76 } 77 78 var notifiesInvitations: Bool { 79 didSet { local.set(notifiesInvitations, forKey: Keys.notifiesInvitations) } 80 } 81 82 init( 83 local: UserDefaults = .standard, 84 cloud: NSUbiquitousKeyValueStore = .default 85 ) { 86 self.local = local 87 self.cloud = cloud 88 self.colorID = cloud.string(forKey: Keys.colorID) 89 ?? local.string(forKey: Keys.colorID) 90 ?? PlayerColor.blue.id 91 let storedName = cloud.string(forKey: Keys.name) 92 ?? local.string(forKey: Keys.name) 93 self.name = storedName ?? "Player" 94 self.hasName = storedName.map(Self.isUsableName) ?? false 95 self.isICloudSyncEnabled = local.object(forKey: Keys.isICloudSyncEnabled) as? Bool ?? true 96 self.notifiesNudges = local.object(forKey: Keys.notifiesNudges) as? Bool ?? true 97 self.notifiesJoins = local.object(forKey: Keys.notifiesJoins) as? Bool ?? true 98 self.notifiesPauses = local.object(forKey: Keys.notifiesPauses) as? Bool ?? true 99 self.notifiesCompletions = local.object(forKey: Keys.notifiesCompletions) as? Bool ?? true 100 self.notifiesInvitations = local.object(forKey: Keys.notifiesInvitations) as? Bool ?? true 101 cloud.synchronize() 102 NotificationCenter.default.addObserver( 103 forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, 104 object: cloud, 105 queue: .main 106 ) { [weak self] _ in 107 MainActor.assumeIsolated { 108 self?.pullFromCloud() 109 } 110 } 111 } 112 113 private func write(_ key: String, _ value: String) { 114 local.set(value, forKey: key) 115 cloud.set(value, forKey: key) 116 } 117 118 private func pullFromCloud() { 119 if let id = cloud.string(forKey: Keys.colorID), id != colorID { 120 colorID = id 121 } 122 if let newName = cloud.string(forKey: Keys.name), newName != name { 123 name = newName 124 } 125 } 126 127 private static func isUsableName(_ name: String) -> Bool { 128 !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 129 } 130 }