Signed-off-by: Matt Bruce <matt.bruce1@toyota.com>

This commit is contained in:
Matt Bruce 2025-12-28 20:12:34 -06:00
parent d16bf6eb2e
commit f1c579244c
7 changed files with 353 additions and 86 deletions

View File

@ -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()
}
}

View File

@ -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" : {

View File

@ -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
}
}

View File

@ -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)

View File

@ -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)

View File

@ -37,6 +37,16 @@ public final class CloudSyncManager<T: PersistableGameData> {
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<T: PersistableGameData> {
// 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,26 +119,28 @@ public final class CloudSyncManager<T: PersistableGameData> {
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 iCloudAvailable && iCloudEnabled {
iCloudStore.synchronize()
// Trigger iCloud sync FIRST (before loading local)
if iCloudEnabled {
store.synchronize()
}
}
// Load data (may get updated when iCloud sync completes)
@ -129,14 +163,20 @@ public final class CloudSyncManager<T: PersistableGameData> {
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<T: PersistableGameData> {
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<T: PersistableGameData> {
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<T: PersistableGameData> {
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<T: PersistableGameData> {
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

View File

@ -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
}