PlayerPreferences.swift (9133B)
1 import Observation 2 import SwiftUI 3 4 /// Player preferences, persisted locally and, where appropriate, synced across 5 /// the user's devices. 6 /// 7 /// Persistence is layered: values are written to `UserDefaults` for fast local 8 /// reads and to `NSUbiquitousKeyValueStore` so they follow the user across 9 /// their devices. On launch we prefer the cloud copy if present, falling back 10 /// to the local copy, then to defaults. External changes (from another 11 /// device) are observed and applied live. 12 /// 13 /// This object is anchored in `RootView`'s `@State` and lives for the app's 14 /// lifetime; no `deinit` / observer teardown is required. 15 @Observable 16 @MainActor 17 final class PlayerPreferences { 18 enum AnswerEndMovement: String { 19 case doNothing 20 case wrapInCurrentAnswer 21 case moveToNextAnswer 22 } 23 24 private enum Keys { 25 static let colorID = "playerColorID" 26 static let name = "playerName" 27 static let isICloudSyncEnabled = "isICloudSyncEnabled" 28 static let notifiesNudges = "notifiesNudges" 29 static let notifiesJoins = "notifiesJoins" 30 static let notifiesPauses = "notifiesPauses" 31 static let notifiesCompletions = "notifiesCompletions" 32 static let notifiesInvitations = "notifiesInvitations" 33 static let skipsFilledCells = "skipsFilledCells" 34 static let answerEndMovement = "answerEndMovement" 35 // Retained only to migrate the earlier pair of movement toggles. 36 static let wrapsToFirstBlank = "wrapsToFirstBlank" 37 static let movesToNextClue = "movesToNextClue" 38 } 39 40 private let local: UserDefaults 41 private let cloud: NSUbiquitousKeyValueStore? 42 43 var colorID: String { 44 didSet { write(Keys.colorID, colorID) } 45 } 46 47 var name: String { 48 didSet { 49 hasName = Self.isUsableName(name) 50 write(Keys.name, name) 51 } 52 } 53 54 var hasName: Bool 55 56 /// Debugging switch for comparing on-device responsiveness with CloudKit 57 /// work paused. Stored locally only so disabling sync on one device does 58 /// not silently disable it everywhere. 59 var isICloudSyncEnabled: Bool { 60 didSet { local.set(isICloudSyncEnabled, forKey: Keys.isICloudSyncEnabled) } 61 } 62 63 var color: PlayerColor { 64 get { PlayerColor.color(for: colorID) } 65 set { colorID = newValue.id } 66 } 67 68 /// Notification preferences are per-device, like the system's own 69 /// notification settings, so they are stored locally only. The first three 70 /// gate worker pushes (the device registers the kinds it has muted and the 71 /// worker drops them before APNs); `notifiesInvitations` gates the local 72 /// notification posted when an invite Ping arrives — the invite itself 73 /// still appears in the Invited section either way. 74 var notifiesNudges: Bool { 75 didSet { local.set(notifiesNudges, forKey: Keys.notifiesNudges) } 76 } 77 78 var notifiesJoins: Bool { 79 didSet { local.set(notifiesJoins, forKey: Keys.notifiesJoins) } 80 } 81 82 var notifiesPauses: Bool { 83 didSet { local.set(notifiesPauses, forKey: Keys.notifiesPauses) } 84 } 85 86 var notifiesCompletions: Bool { 87 didSet { local.set(notifiesCompletions, forKey: Keys.notifiesCompletions) } 88 } 89 90 var notifiesInvitations: Bool { 91 didSet { local.set(notifiesInvitations, forKey: Keys.notifiesInvitations) } 92 } 93 94 /// Whether automatic cursor movement avoids cells that already contain an 95 /// entry. This follows the user across devices so behaviour stays consistent. 96 var skipsFilledCells: Bool { 97 didSet { write(Keys.skipsFilledCells, skipsFilledCells) } 98 } 99 100 /// Where typing at the final square of an answer continues. When wrapping, 101 /// `skipsFilledCells` decides whether the destination is the first square 102 /// or the first blank square. 103 var answerEndMovement: AnswerEndMovement { 104 didSet { write(Keys.answerEndMovement, answerEndMovement.rawValue) } 105 } 106 107 init( 108 local: UserDefaults = .standard, 109 cloud: NSUbiquitousKeyValueStore? = .default 110 ) { 111 self.local = local 112 self.cloud = cloud 113 self.colorID = cloud?.string(forKey: Keys.colorID) 114 ?? local.string(forKey: Keys.colorID) 115 ?? PlayerColor.blue.id 116 let storedName = cloud?.string(forKey: Keys.name) 117 ?? local.string(forKey: Keys.name) 118 self.name = storedName ?? "Player" 119 self.hasName = storedName.map(Self.isUsableName) ?? false 120 self.isICloudSyncEnabled = local.object(forKey: Keys.isICloudSyncEnabled) as? Bool ?? true 121 self.notifiesNudges = local.object(forKey: Keys.notifiesNudges) as? Bool ?? true 122 self.notifiesJoins = local.object(forKey: Keys.notifiesJoins) as? Bool ?? true 123 self.notifiesPauses = local.object(forKey: Keys.notifiesPauses) as? Bool ?? true 124 self.notifiesCompletions = local.object(forKey: Keys.notifiesCompletions) as? Bool ?? true 125 self.notifiesInvitations = local.object(forKey: Keys.notifiesInvitations) as? Bool ?? true 126 self.skipsFilledCells = cloud?.object(forKey: Keys.skipsFilledCells) as? Bool 127 ?? local.object(forKey: Keys.skipsFilledCells) as? Bool 128 ?? true 129 let storedAnswerEndMovement = cloud?.string(forKey: Keys.answerEndMovement) 130 ?? local.string(forKey: Keys.answerEndMovement) 131 if let storedAnswerEndMovement, 132 let movement = AnswerEndMovement(rawValue: storedAnswerEndMovement) { 133 self.answerEndMovement = movement 134 } else { 135 let legacyWraps = cloud?.object(forKey: Keys.wrapsToFirstBlank) as? Bool 136 ?? local.object(forKey: Keys.wrapsToFirstBlank) as? Bool 137 let legacyMoves = cloud?.object(forKey: Keys.movesToNextClue) as? Bool 138 ?? local.object(forKey: Keys.movesToNextClue) as? Bool 139 self.answerEndMovement = Self.migratedAnswerEndMovement( 140 wraps: legacyWraps, 141 moves: legacyMoves 142 ) 143 } 144 guard let cloud else { return } 145 cloud.synchronize() 146 NotificationCenter.default.addObserver( 147 forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, 148 object: cloud, 149 queue: .main 150 ) { [weak self] _ in 151 MainActor.assumeIsolated { 152 self?.pullFromCloud() 153 } 154 } 155 } 156 157 /// A throwaway instance that touches neither iCloud nor the standard 158 /// defaults, so a demo/marketing seed's scratch name and colour can't leak 159 /// into a real launch. The persistent path shares one account-wide 160 /// `NSUbiquitousKeyValueStore`, so a plain instance used for the demo would 161 /// sync "You" to every device on the Apple ID. 162 static func ephemeral() -> PlayerPreferences { 163 let domain = "net.inqk.crossmate.demo" 164 let suite = UserDefaults(suiteName: domain) 165 suite?.removePersistentDomain(forName: domain) 166 return PlayerPreferences(local: suite ?? .standard, cloud: nil) 167 } 168 169 private func write(_ key: String, _ value: String) { 170 local.set(value, forKey: key) 171 cloud?.set(value, forKey: key) 172 } 173 174 private func write(_ key: String, _ value: Bool) { 175 local.set(value, forKey: key) 176 cloud?.set(value, forKey: key) 177 } 178 179 private func pullFromCloud() { 180 guard let cloud else { return } 181 if let id = cloud.string(forKey: Keys.colorID), id != colorID { 182 colorID = id 183 } 184 if let newName = cloud.string(forKey: Keys.name), newName != name { 185 name = newName 186 } 187 if let newSkipsFilledCells = cloud.object(forKey: Keys.skipsFilledCells) as? Bool, 188 newSkipsFilledCells != skipsFilledCells { 189 skipsFilledCells = newSkipsFilledCells 190 } 191 if let rawMovement = cloud.string(forKey: Keys.answerEndMovement), 192 let newAnswerEndMovement = AnswerEndMovement(rawValue: rawMovement), 193 newAnswerEndMovement != answerEndMovement { 194 answerEndMovement = newAnswerEndMovement 195 } else if cloud.object(forKey: Keys.answerEndMovement) == nil { 196 let legacyWraps = cloud.object(forKey: Keys.wrapsToFirstBlank) as? Bool 197 let legacyMoves = cloud.object(forKey: Keys.movesToNextClue) as? Bool 198 guard legacyWraps != nil || legacyMoves != nil else { return } 199 let migratedMovement = Self.migratedAnswerEndMovement( 200 wraps: legacyWraps, 201 moves: legacyMoves 202 ) 203 if migratedMovement != answerEndMovement { 204 answerEndMovement = migratedMovement 205 } 206 } 207 } 208 209 private static func migratedAnswerEndMovement( 210 wraps: Bool?, 211 moves: Bool? 212 ) -> AnswerEndMovement { 213 if wraps == true { return .wrapInCurrentAnswer } 214 if moves == false { return .doNothing } 215 return .moveToNextAnswer 216 } 217 218 private static func isUsableName(_ name: String) -> Bool { 219 !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 220 } 221 }