crossmate

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

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 }