crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }