From f1c579244c933986e1b0521416acc328dac617ea Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Sun, 28 Dec 2025 20:12:34 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Baccarat/Baccarat/Models/GameSettings.swift | 114 ++++++----- .../Baccarat/Resources/Localizable.xcstrings | 184 ++++++++++++++++++ Baccarat/Baccarat/Theme/DesignConstants.swift | 10 +- .../Baccarat/Views/Sheets/RulesHelpView.swift | 15 +- .../Views/Sheets/RulesHelpView.swift | 2 +- .../CasinoKit/Storage/CloudSyncManager.swift | 110 ++++++++--- .../CasinoKit/Theme/CasinoDesign.swift | 4 +- 7 files changed, 353 insertions(+), 86 deletions(-) diff --git a/Baccarat/Baccarat/Models/GameSettings.swift b/Baccarat/Baccarat/Models/GameSettings.swift index cc7ca58..44ba86a 100644 --- a/Baccarat/Baccarat/Models/GameSettings.swift +++ b/Baccarat/Baccarat/Models/GameSettings.swift @@ -165,11 +165,36 @@ final class GameSettings { // MARK: - iCloud - private let iCloudStore = NSUbiquitousKeyValueStore.default + /// Cached reference to iCloud store, lazily initialized + private var _iCloudStore: NSUbiquitousKeyValueStore? + private var _iCloudStoreInitialized = false + + private var iCloudStore: NSUbiquitousKeyValueStore? { + // Return cached value if already attempted initialization + if _iCloudStoreInitialized { + return _iCloudStore + } + + _iCloudStoreInitialized = true + + // Only access the store if iCloud is actually available + guard iCloudAvailable else { + return nil + } + + _iCloudStore = NSUbiquitousKeyValueStore.default + return _iCloudStore + } /// Whether iCloud is available. var iCloudAvailable: Bool { - FileManager.default.ubiquityIdentityToken != nil + guard FileManager.default.ubiquityIdentityToken != nil else { + return false + } + + // Additional check: verify we can actually access ubiquity container + let containerURL = FileManager.default.url(forUbiquityContainerIdentifier: nil) + return containerURL != nil } // MARK: - Initialization @@ -177,20 +202,19 @@ final class GameSettings { init() { load() - // Register for iCloud changes - NotificationCenter.default.addObserver( - forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, - object: iCloudStore, - queue: .main - ) { [weak self] _ in - Task { @MainActor in + // Register for iCloud changes (only if available) + if iCloudAvailable, let store = iCloudStore { + NotificationCenter.default.addObserver( + forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, + object: store, + queue: .main + ) { [weak self] _ in + // Already on main queue, safe to call self?.loadFromiCloud() } - } - - // Trigger iCloud sync - if iCloudAvailable { - iCloudStore.synchronize() + + // Trigger iCloud sync + store.synchronize() } } @@ -260,51 +284,51 @@ final class GameSettings { /// Loads settings from iCloud. private func loadFromiCloud() { - guard iCloudAvailable else { return } + guard iCloudAvailable, let store = iCloudStore else { return } - if let rawDeckCount = iCloudStore.object(forKey: Keys.deckCount) as? Int, + if let rawDeckCount = store.object(forKey: Keys.deckCount) as? Int, let deckCount = DeckCount(rawValue: rawDeckCount) { self.deckCount = deckCount } - if let rawTableLimits = iCloudStore.string(forKey: Keys.tableLimits), + if let rawTableLimits = store.string(forKey: Keys.tableLimits), let tableLimits = TableLimits(rawValue: rawTableLimits) { self.tableLimits = tableLimits } - if let balance = iCloudStore.object(forKey: Keys.startingBalance) as? Int { + if let balance = store.object(forKey: Keys.startingBalance) as? Int { self.startingBalance = balance } - if iCloudStore.object(forKey: Keys.showAnimations) != nil { - self.showAnimations = iCloudStore.bool(forKey: Keys.showAnimations) + if store.object(forKey: Keys.showAnimations) != nil { + self.showAnimations = store.bool(forKey: Keys.showAnimations) } - if let speed = iCloudStore.object(forKey: Keys.dealingSpeed) as? Double { + if let speed = store.object(forKey: Keys.dealingSpeed) as? Double { self.dealingSpeed = speed } - if iCloudStore.object(forKey: Keys.showCardsRemaining) != nil { - self.showCardsRemaining = iCloudStore.bool(forKey: Keys.showCardsRemaining) + if store.object(forKey: Keys.showCardsRemaining) != nil { + self.showCardsRemaining = store.bool(forKey: Keys.showCardsRemaining) } - if iCloudStore.object(forKey: Keys.showHistory) != nil { - self.showHistory = iCloudStore.bool(forKey: Keys.showHistory) + if store.object(forKey: Keys.showHistory) != nil { + self.showHistory = store.bool(forKey: Keys.showHistory) } - if iCloudStore.object(forKey: Keys.showHints) != nil { - self.showHints = iCloudStore.bool(forKey: Keys.showHints) + if store.object(forKey: Keys.showHints) != nil { + self.showHints = store.bool(forKey: Keys.showHints) } - if iCloudStore.object(forKey: Keys.soundEnabled) != nil { - self.soundEnabled = iCloudStore.bool(forKey: Keys.soundEnabled) + if store.object(forKey: Keys.soundEnabled) != nil { + self.soundEnabled = store.bool(forKey: Keys.soundEnabled) } - if iCloudStore.object(forKey: Keys.hapticsEnabled) != nil { - self.hapticsEnabled = iCloudStore.bool(forKey: Keys.hapticsEnabled) + if store.object(forKey: Keys.hapticsEnabled) != nil { + self.hapticsEnabled = store.bool(forKey: Keys.hapticsEnabled) } - if let volume = iCloudStore.object(forKey: Keys.soundVolume) as? Double { + if let volume = store.object(forKey: Keys.soundVolume) as? Double { self.soundVolume = Float(volume) } } @@ -326,19 +350,19 @@ final class GameSettings { defaults.set(soundVolume, forKey: Keys.soundVolume) // Also save to iCloud - if iCloudAvailable { - iCloudStore.set(deckCount.rawValue, forKey: Keys.deckCount) - iCloudStore.set(tableLimits.rawValue, forKey: Keys.tableLimits) - iCloudStore.set(startingBalance, forKey: Keys.startingBalance) - iCloudStore.set(showAnimations, forKey: Keys.showAnimations) - iCloudStore.set(dealingSpeed, forKey: Keys.dealingSpeed) - iCloudStore.set(showCardsRemaining, forKey: Keys.showCardsRemaining) - iCloudStore.set(showHistory, forKey: Keys.showHistory) - iCloudStore.set(showHints, forKey: Keys.showHints) - iCloudStore.set(soundEnabled, forKey: Keys.soundEnabled) - iCloudStore.set(hapticsEnabled, forKey: Keys.hapticsEnabled) - iCloudStore.set(Double(soundVolume), forKey: Keys.soundVolume) - iCloudStore.synchronize() + if iCloudAvailable, let store = iCloudStore { + store.set(deckCount.rawValue, forKey: Keys.deckCount) + store.set(tableLimits.rawValue, forKey: Keys.tableLimits) + store.set(startingBalance, forKey: Keys.startingBalance) + store.set(showAnimations, forKey: Keys.showAnimations) + store.set(dealingSpeed, forKey: Keys.dealingSpeed) + store.set(showCardsRemaining, forKey: Keys.showCardsRemaining) + store.set(showHistory, forKey: Keys.showHistory) + store.set(showHints, forKey: Keys.showHints) + store.set(soundEnabled, forKey: Keys.soundEnabled) + store.set(hapticsEnabled, forKey: Keys.hapticsEnabled) + store.set(Double(soundVolume), forKey: Keys.soundVolume) + store.synchronize() } } diff --git a/Baccarat/Baccarat/Resources/Localizable.xcstrings b/Baccarat/Baccarat/Resources/Localizable.xcstrings index 19e0202..a5a18b6 100644 --- a/Baccarat/Baccarat/Resources/Localizable.xcstrings +++ b/Baccarat/Baccarat/Resources/Localizable.xcstrings @@ -3318,6 +3318,190 @@ } } }, + "History Display" : { + "comment" : "Title of a section in the Rules Help view explaining the history feature.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "History Display" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visualización del historial" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Affichage de l'historique" + } + } + } + }, + "The History shows all previous round results at a glance." : { + "comment" : "Explains the purpose of the history display.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The History shows all previous round results at a glance." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "El Historial muestra todos los resultados de rondas anteriores de un vistazo." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'historique affiche tous les résultats des tours précédents en un coup d'œil." + } + } + } + }, + "Blue Circle (P): Player won the hand" : { + "comment" : "Explains the blue circle icon in the history.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blue Circle (P): Player won the hand" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Círculo Azul (P): El jugador ganó la mano" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cercle Bleu (P) : Le joueur a gagné la main" + } + } + } + }, + "Red Circle (B): Banker won the hand" : { + "comment" : "Explains the red circle icon in the history.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Red Circle (B): Banker won the hand" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Círculo Rojo (B): El banquero ganó la mano" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cercle Rouge (B) : Le banquier a gagné la main" + } + } + } + }, + "Green Circle (T): Tie between Player and Banker" : { + "comment" : "Explains the green circle icon in the history.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Green Circle (T): Tie between Player and Banker" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Círculo Verde (T): Empate entre el jugador y el banquero" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cercle Vert (T) : Égalité entre le joueur et le banquier" + } + } + } + }, + "Yellow Dot (bottom-left): A pair occurred in that hand" : { + "comment" : "Explains the yellow dot marker in the history.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yellow Dot (bottom-left): A pair occurred in that hand" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Punto Amarillo (abajo-izquierda): Hubo un par en esa mano" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Point Jaune (en bas à gauche) : Une paire s'est produite dans cette main" + } + } + } + }, + "Yellow Star (top-right): Natural 8 or 9 win" : { + "comment" : "Explains the yellow star marker in the history.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yellow Star (top-right): Natural 8 or 9 win" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estrella Amarilla (arriba-derecha): Victoria natural con 8 o 9" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Étoile Jaune (en haut à droite) : Victoire naturelle avec 8 ou 9" + } + } + } + }, + "Use History to spot patterns and trends in the shoe." : { + "comment" : "Advises the player on how to use the history feature.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use History to spot patterns and trends in the shoe." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usa el Historial para detectar patrones y tendencias en el zapato." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilisez l'historique pour repérer les modèles et les tendances dans le sabot." + } + } + } + }, "Strategy Tips" : { "comment" : "Title of a section in the Rules Help view focused on strategy tips.", "localizations" : { diff --git a/Baccarat/Baccarat/Theme/DesignConstants.swift b/Baccarat/Baccarat/Theme/DesignConstants.swift index 75ea3a6..397bb1a 100644 --- a/Baccarat/Baccarat/Theme/DesignConstants.swift +++ b/Baccarat/Baccarat/Theme/DesignConstants.swift @@ -14,7 +14,7 @@ import CasinoKit enum Design { /// Set to true to show layout debug borders on views - static let showDebugBorders = true + static let showDebugBorders = false // MARK: - Shared Constants (from CasinoKit) @@ -59,10 +59,10 @@ enum Design { // MARK: - Card Deal Animation enum DealAnimation { - /// Horizontal offset for card deal (from upper-center, simulating dealer) - static let offsetX: CGFloat = 0 - /// Vertical offset for card deal (from above the table) - static let offsetY: CGFloat = -250 + /// Horizontal offset for card deal (shoe position in upper-right area) + static let offsetX: CGFloat = 150 + /// Vertical offset for card deal (from top of screen where shoe is positioned) + static let offsetY: CGFloat = -300 } } diff --git a/Baccarat/Baccarat/Views/Sheets/RulesHelpView.swift b/Baccarat/Baccarat/Views/Sheets/RulesHelpView.swift index 9181eff..de655ec 100644 --- a/Baccarat/Baccarat/Views/Sheets/RulesHelpView.swift +++ b/Baccarat/Baccarat/Views/Sheets/RulesHelpView.swift @@ -103,6 +103,19 @@ struct RulesHelpView: View { String(localized: "Independent of the main game result.") ] ), + RulePage( + title: String(localized: "History Display"), + icon: "clock.fill", + content: [ + String(localized: "The History shows all previous round results at a glance."), + String(localized: "Blue Circle (P): Player won the hand"), + String(localized: "Red Circle (B): Banker won the hand"), + String(localized: "Green Circle (T): Tie between Player and Banker"), + String(localized: "Yellow Dot (bottom-left): A pair occurred in that hand"), + String(localized: "Yellow Star (top-right): Natural 8 or 9 win"), + String(localized: "Use History to spot patterns and trends in the shoe.") + ] + ), RulePage( title: String(localized: "Strategy Tips"), icon: "lightbulb.fill", @@ -195,7 +208,7 @@ struct RulePageView: View { // Content VStack(alignment: .leading, spacing: Design.Spacing.medium) { ForEach(page.content.indices, id: \.self) { index in - HStack(alignment: .top, spacing: Design.Spacing.medium) { + HStack(alignment: .firstTextBaseline, spacing: Design.Spacing.medium) { Text("•") .foregroundStyle(Color.Sheet.accent) diff --git a/Blackjack/Blackjack/Views/Sheets/RulesHelpView.swift b/Blackjack/Blackjack/Views/Sheets/RulesHelpView.swift index e32959b..9fce463 100644 --- a/Blackjack/Blackjack/Views/Sheets/RulesHelpView.swift +++ b/Blackjack/Blackjack/Views/Sheets/RulesHelpView.swift @@ -303,7 +303,7 @@ struct RulePageView: View { // Content VStack(alignment: .leading, spacing: Design.Spacing.medium) { ForEach(page.content.indices, id: \.self) { index in - HStack(alignment: .top, spacing: Design.Spacing.medium) { + HStack(alignment: .firstTextBaseline, spacing: Design.Spacing.medium) { Text("•") .foregroundStyle(Color.Sheet.accent) diff --git a/CasinoKit/Sources/CasinoKit/Storage/CloudSyncManager.swift b/CasinoKit/Sources/CasinoKit/Storage/CloudSyncManager.swift index 84fc0f0..b316e19 100644 --- a/CasinoKit/Sources/CasinoKit/Storage/CloudSyncManager.swift +++ b/CasinoKit/Sources/CasinoKit/Storage/CloudSyncManager.swift @@ -37,6 +37,16 @@ public final class CloudSyncManager { public var iCloudAvailable: Bool { let token = FileManager.default.ubiquityIdentityToken let available = token != nil + + // Additional check: verify we can actually access ubiquity container + // This prevents false positives in simulators + if available { + let containerURL = FileManager.default.url(forUbiquityContainerIdentifier: nil) + let actuallyAvailable = containerURL != nil + CasinoDesign.debugLog("CloudSyncManager: iCloud token = \(String(describing: token)), container = \(String(describing: containerURL))") + return actuallyAvailable + } + CasinoDesign.debugLog("CloudSyncManager: iCloud available = \(available), token = \(String(describing: token))") return available } @@ -61,7 +71,29 @@ public final class CloudSyncManager { // MARK: - Private Properties - private let iCloudStore = NSUbiquitousKeyValueStore.default + /// Cached reference to iCloud store, lazily initialized + private var _iCloudStore: NSUbiquitousKeyValueStore? + private var _iCloudStoreInitialized = false + + private var iCloudStore: NSUbiquitousKeyValueStore? { + // Return cached value if already attempted initialization + if _iCloudStoreInitialized { + return _iCloudStore + } + + _iCloudStoreInitialized = true + + // Only access the store if iCloud is actually available + guard iCloudAvailable else { + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: iCloud not available, skipping NSUbiquitousKeyValueStore access") + return nil + } + + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Accessing NSUbiquitousKeyValueStore.default...") + _iCloudStore = NSUbiquitousKeyValueStore.default + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: NSUbiquitousKeyValueStore.default accessed successfully") + return _iCloudStore + } private let encoder = JSONEncoder() private let decoder = JSONDecoder() @@ -87,28 +119,30 @@ public final class CloudSyncManager { UserDefaults.standard.set(true, forKey: iCloudEnabledKey) } - // Register for iCloud changes BEFORE syncing - NotificationCenter.default.addObserver( - forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, - object: iCloudStore, - queue: .main - ) { [weak self] notification in - // Extract values before crossing isolation boundary (for Sendable compliance) - guard let userInfo = notification.userInfo, - let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int, - let changedKeys = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String] else { - return + // Register for iCloud changes BEFORE syncing (only if available) + if iCloudAvailable, let store = iCloudStore { + NotificationCenter.default.addObserver( + forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, + object: store, + queue: .main + ) { [weak self] notification in + // Extract values before crossing isolation boundary (for Sendable compliance) + guard let userInfo = notification.userInfo, + let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int, + let changedKeys = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String] else { + return + } + Task { @MainActor in + self?.handleCloudChange(reason: reason, changedKeys: changedKeys) + } } - Task { @MainActor in - self?.handleCloudChange(reason: reason, changedKeys: changedKeys) + + // Trigger iCloud sync FIRST (before loading local) + if iCloudEnabled { + store.synchronize() } } - // Trigger iCloud sync FIRST (before loading local) - if iCloudAvailable && iCloudEnabled { - iCloudStore.synchronize() - } - // Load data (may get updated when iCloud sync completes) self.data = load() @@ -129,14 +163,20 @@ public final class CloudSyncManager { CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Delayed check - forcing sync...") // Force another sync (on main thread to avoid concurrency warning) + guard let store = iCloudStore else { + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: iCloud store unavailable") + hasCompletedInitialSync = true + return + } + var syncResult = false await MainActor.run { - syncResult = iCloudStore.synchronize() + syncResult = store.synchronize() } CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: synchronize() returned \(syncResult)") // Check what's in the store - let allKeys = iCloudStore.dictionaryRepresentation.keys + let allKeys = store.dictionaryRepresentation.keys CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: iCloud store keys: \(Array(allKeys))") // Try loading cloud data again @@ -186,10 +226,10 @@ public final class CloudSyncManager { UserDefaults.standard.set(encoded, forKey: localKey) // Save to iCloud - if iCloudAvailable && iCloudEnabled { - iCloudStore.set(encoded, forKey: cloudKey) - iCloudStore.set(Date(), forKey: syncDateKey) - iCloudStore.synchronize() + if iCloudAvailable && iCloudEnabled, let store = iCloudStore { + store.set(encoded, forKey: cloudKey) + store.set(Date(), forKey: syncDateKey) + store.synchronize() lastSyncDate = Date() syncStatus = "Synced" } @@ -258,12 +298,13 @@ public final class CloudSyncManager { private func loadCloud() -> T? { guard iCloudAvailable && iCloudEnabled, - let data = iCloudStore.data(forKey: cloudKey), + let store = iCloudStore, + let data = store.data(forKey: cloudKey), let decoded = try? decoder.decode(T.self, from: data) else { return nil } - if let syncDate = iCloudStore.object(forKey: syncDateKey) as? Date { + if let syncDate = store.object(forKey: syncDateKey) as? Date { lastSyncDate = syncDate } @@ -279,10 +320,15 @@ public final class CloudSyncManager { return } + guard let store = iCloudStore else { + syncStatus = "iCloud unavailable" + return + } + isSyncing = true syncStatus = "Syncing..." - iCloudStore.synchronize() + store.synchronize() // Reload to get any changes let latestData = load() @@ -347,10 +393,10 @@ public final class CloudSyncManager { public func reset() { UserDefaults.standard.removeObject(forKey: localKey) - if iCloudAvailable { - iCloudStore.removeObject(forKey: cloudKey) - iCloudStore.removeObject(forKey: syncDateKey) - iCloudStore.synchronize() + if iCloudAvailable, let store = iCloudStore { + store.removeObject(forKey: cloudKey) + store.removeObject(forKey: syncDateKey) + store.synchronize() } data = T.empty diff --git a/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift b/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift index 04284f2..5b5afc4 100644 --- a/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift +++ b/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift @@ -13,13 +13,13 @@ public enum CasinoDesign { // MARK: - Debug /// Set to true to enable debug logging in CasinoKit. - public static let showDebugLogs = false + public static let showDebugLogs = true /// Logs a message only in debug builds when `showDebugLogs` is enabled. public static func debugLog(_ message: String) { #if DEBUG if showDebugLogs { - print(message) + print("[CasinoKit] \(message)") } #endif }