diff --git a/Blackjack/Blackjack/Resources/Localizable.xcstrings b/Blackjack/Blackjack/Resources/Localizable.xcstrings index 28751e0..2a62cb9 100644 --- a/Blackjack/Blackjack/Resources/Localizable.xcstrings +++ b/Blackjack/Blackjack/Resources/Localizable.xcstrings @@ -3480,28 +3480,6 @@ } } }, - "Hint: %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hint: %@" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Consejo: %@" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Conseil: %@" - } - } - } - }, "Hit" : { "localizations" : { "en" : { diff --git a/Blackjack/Blackjack/Theme/DesignConstants.swift b/Blackjack/Blackjack/Theme/DesignConstants.swift index 99b948a..855cd93 100644 --- a/Blackjack/Blackjack/Theme/DesignConstants.swift +++ b/Blackjack/Blackjack/Theme/DesignConstants.swift @@ -62,6 +62,7 @@ enum Design { static let hintIconSize: CGFloat = 24 static let hintPaddingH: CGFloat = 10 static let hintPaddingV: CGFloat = 10 + static let hintMinWidth: CGFloat = 90 // Hand icons static let handIconSize: CGFloat = 18 diff --git a/Blackjack/Blackjack/Views/Game/GameTableView.swift b/Blackjack/Blackjack/Views/Game/GameTableView.swift index 51d9c8c..fdf598e 100644 --- a/Blackjack/Blackjack/Views/Game/GameTableView.swift +++ b/Blackjack/Blackjack/Views/Game/GameTableView.swift @@ -42,29 +42,28 @@ struct GameTableView: View { return .infinity } + /// Provides the current game state, creating one if needed (fallback for initial render). + private var state: GameState { + gameState ?? GameState(settings: settings) + } + // MARK: - Body var body: some View { - Group { - if let state = gameState { - mainGameView(state: state) - } else { - ProgressView() - .task { - gameState = GameState(settings: settings) - } + mainGameView(state: state) + .onAppear { + if gameState == nil { + gameState = GameState(settings: settings) + } } - } - .sheet(isPresented: $showSettings) { + .sheet(isPresented: $showSettings) { SettingsView(settings: settings, gameState: gameState) } .sheet(isPresented: $showRules) { RulesHelpView() } .sheet(isPresented: $showStats) { - if let state = gameState { - StatisticsSheetView(state: state) - } + StatisticsSheetView(state: state) } } diff --git a/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift b/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift index d0b0a8e..78bd297 100644 --- a/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift +++ b/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift @@ -61,6 +61,29 @@ struct BlackjackTableView: View { // Use global debug flag from Design constants private var showDebugBorders: Bool { Design.showDebugBorders } + // MARK: - Hint Toast Helper + + /// Shows the hint toast with auto-dismiss timer. + private func showHintToastWithTimer(state: GameState) { + // Generate new ID to invalidate any pending dismiss tasks + let currentID = UUID() + state.hintDisplayID = currentID + + withAnimation(.spring(duration: Design.Animation.springDuration)) { + state.showHintToast = true + } + // Auto-dismiss after delay, but only if this is still the active hint session + Task { @MainActor in + try? await Task.sleep(for: Design.Toast.duration) + // Only dismiss if no newer hint has arrived + if state.hintDisplayID == currentID { + withAnimation(.spring(duration: Design.Animation.springDuration)) { + state.showHintToast = false + } + } + } + } + var body: some View { VStack(spacing: 0) { // Dealer area @@ -99,7 +122,9 @@ struct BlackjackTableView: View { isPlayerTurn: state.isPlayerTurn, showCardCount: showCardCount, cardWidth: cardWidth, - cardSpacing: cardSpacing + cardSpacing: cardSpacing, + currentHint: state.currentHint, + showHintToast: state.showHintToast ) // Side bet toasts (positioned on left/right sides to not cover cards) @@ -132,33 +157,10 @@ struct BlackjackTableView: View { .padding(.horizontal, Design.Spacing.small) } } - .overlay { - // Hint toast overlaying player hands (auto-dismisses) - if state.showHintToast, let hint = state.currentHint { - HintView(hint: hint) - .transition(.scale.combined(with: .opacity)) - } - } .onChange(of: state.currentHint) { oldHint, newHint in // Show toast when a new hint appears if let hint = newHint, hint != oldHint { - // Generate new ID to invalidate any pending dismiss tasks - let currentID = UUID() - state.hintDisplayID = currentID - - withAnimation(.spring(duration: Design.Animation.springDuration)) { - state.showHintToast = true - } - // Auto-dismiss after delay, but only if this is still the active hint session - Task { @MainActor in - try? await Task.sleep(for: Design.Toast.duration) - // Only dismiss if no newer hint has arrived - if state.hintDisplayID == currentID { - withAnimation(.spring(duration: Design.Animation.springDuration)) { - state.showHintToast = false - } - } - } + showHintToastWithTimer(state: state) } else if newHint == nil { // Hide immediately when no hint state.hintDisplayID = UUID() // Invalidate any pending dismiss tasks @@ -167,6 +169,29 @@ struct BlackjackTableView: View { } } } + .onChange(of: state.playerHands.count) { _, _ in + // Show hint when hands are added (split occurred) + if state.currentHint != nil { + showHintToastWithTimer(state: state) + } + } + .onChange(of: state.activeHandIndex) { _, _ in + // Show hint when active hand changes (moved to next hand after split) + if state.currentHint != nil { + showHintToastWithTimer(state: state) + } + } + .onChange(of: state.activeHand?.cards.count) { _, newCount in + // Show hint when a card is added to the active hand (after hit) + guard let count = newCount, count > 2, state.currentHint != nil else { return } + // Small delay to let card animation settle before showing hint + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(250)) + if state.currentHint != nil { + showHintToastWithTimer(state: state) + } + } + } .padding(.bottom, 5) .transition(.opacity) .debugBorder(showDebugBorders, color: .green, label: "Player") diff --git a/Blackjack/Blackjack/Views/Table/HintViews.swift b/Blackjack/Blackjack/Views/Table/HintViews.swift index 07de347..6dfc8be 100644 --- a/Blackjack/Blackjack/Views/Table/HintViews.swift +++ b/Blackjack/Blackjack/Views/Table/HintViews.swift @@ -24,7 +24,7 @@ struct HintView: View { Image(systemName: "lightbulb.fill") .font(.system(size: iconSize)) .foregroundStyle(.yellow) - Text(String(localized: "Hint: \(hint)")) + Text(hint) .font(.system(size: fontSize, weight: .medium)) .foregroundStyle(.white) .lineLimit(1) @@ -32,6 +32,7 @@ struct HintView: View { } .padding(.horizontal, paddingH) .padding(.vertical, paddingV) + .frame(minWidth: Design.Size.hintMinWidth, alignment: .leading) .background( Capsule() .fill(Color.black.opacity(Design.Opacity.heavy)) diff --git a/Blackjack/Blackjack/Views/Table/InsurancePopupView.swift b/Blackjack/Blackjack/Views/Table/InsurancePopupView.swift index bf6217e..d5f17b7 100644 --- a/Blackjack/Blackjack/Views/Table/InsurancePopupView.swift +++ b/Blackjack/Blackjack/Views/Table/InsurancePopupView.swift @@ -14,6 +14,8 @@ struct InsurancePopupView: View { let onTake: () -> Void let onDecline: () -> Void + @State private var showContent = false + var body: some View { ZStack { // Dimmed background @@ -91,6 +93,13 @@ struct InsurancePopupView: View { RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge) .strokeBorder(Color.yellow.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin) ) + .scaleEffect(showContent ? Design.Scale.normal : Design.Scale.slightShrink) + .opacity(showContent ? 1.0 : 0) + } + .onAppear { + withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce)) { + showContent = true + } } .accessibilityElement(children: .contain) .accessibilityAddTraits(.isModal) diff --git a/Blackjack/Blackjack/Views/Table/PlayerHandView.swift b/Blackjack/Blackjack/Views/Table/PlayerHandView.swift index 1b49980..79bfc33 100644 --- a/Blackjack/Blackjack/Views/Table/PlayerHandView.swift +++ b/Blackjack/Blackjack/Views/Table/PlayerHandView.swift @@ -19,6 +19,12 @@ struct PlayerHandsView: View { let cardWidth: CGFloat let cardSpacing: CGFloat + /// Current hint to display (shown on active hand only). + let currentHint: String? + + /// Whether the hint toast should be visible. + let showHintToast: Bool + /// Total card count across all hands - used to trigger scroll when hitting private var totalCardCount: Int { hands.reduce(0) { $0 + $1.cards.count } @@ -32,14 +38,18 @@ struct PlayerHandsView: View { // Visual order: Hand 3, Hand 2, Hand 1 (left to right) // Play order: Hand 1 played first (rightmost), then Hand 2, etc. ForEach(Array(hands.enumerated()).reversed(), id: \.element.id) { index, hand in + let isActiveHand = index == activeHandIndex && isPlayerTurn PlayerHandView( hand: hand, - isActive: index == activeHandIndex && isPlayerTurn, + isActive: isActiveHand, showCardCount: showCardCount, // Hand numbers: rightmost (index 0) is Hand 1, played first handNumber: hands.count > 1 ? index + 1 : nil, cardWidth: cardWidth, - cardSpacing: cardSpacing + cardSpacing: cardSpacing, + // Only show hint on the active hand + currentHint: isActiveHand ? currentHint : nil, + showHintToast: isActiveHand && showHintToast ) .id(hand.id) .transition(.scale.combined(with: .opacity)) @@ -89,6 +99,12 @@ struct PlayerHandView: View { let cardWidth: CGFloat let cardSpacing: CGFloat + /// Current hint to display on this hand. + let currentHint: String? + + /// Whether the hint toast should be visible. + let showHintToast: Bool + @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize @ScaledMetric(relativeTo: .caption) private var handNumberSize: CGFloat = Design.Size.handNumberFontSize @ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = Design.Size.handIconSize @@ -132,7 +148,7 @@ struct PlayerHandView: View { ) ) .overlay { - // Result badge - centered on cards + // Result badge - centered on cards (takes priority over hint) if let result = hand.result { Text(result.displayText) .font(.system(size: labelFontSize, weight: .black)) @@ -145,6 +161,10 @@ struct PlayerHandView: View { .shadow(color: .black.opacity(Design.Opacity.medium), radius: Design.Shadow.radiusMedium) ) .transition(.scale.combined(with: .opacity)) + } else if showHintToast, let hint = currentHint { + // Hint toast - centered on active hand (only when no result) + HintView(hint: hint) + .transition(.scale.combined(with: .opacity)) } } .contentShape(Rectangle()) @@ -218,7 +238,9 @@ struct PlayerHandView: View { isPlayerTurn: true, showCardCount: false, cardWidth: 60, - cardSpacing: -20 + cardSpacing: -20, + currentHint: nil, + showHintToast: false ) } } @@ -235,7 +257,9 @@ struct PlayerHandView: View { isPlayerTurn: true, showCardCount: false, cardWidth: 60, - cardSpacing: -20 + cardSpacing: -20, + currentHint: "Hit", + showHintToast: true ) } } @@ -266,7 +290,9 @@ struct PlayerHandView: View { isPlayerTurn: true, showCardCount: true, cardWidth: 60, - cardSpacing: -20 + cardSpacing: -20, + currentHint: "Stand", + showHintToast: true ) } } diff --git a/CasinoKit/Sources/CasinoKit/Storage/CloudSyncManager.swift b/CasinoKit/Sources/CasinoKit/Storage/CloudSyncManager.swift index 204eb55..84fc0f0 100644 --- a/CasinoKit/Sources/CasinoKit/Storage/CloudSyncManager.swift +++ b/CasinoKit/Sources/CasinoKit/Storage/CloudSyncManager.swift @@ -37,7 +37,7 @@ public final class CloudSyncManager { public var iCloudAvailable: Bool { let token = FileManager.default.ubiquityIdentityToken let available = token != nil - print("CloudSyncManager: iCloud available = \(available), token = \(String(describing: token))") + CasinoDesign.debugLog("CloudSyncManager: iCloud available = \(available), token = \(String(describing: token))") return available } @@ -120,30 +120,30 @@ public final class CloudSyncManager { /// Checks for iCloud data after a brief delay (for fresh installs). private func scheduleDelayedCloudCheck() { - print("CloudSyncManager[\(T.gameIdentifier)]: Scheduling delayed cloud check...") + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Scheduling delayed cloud check...") Task { @MainActor in // Wait for iCloud to sync (typically takes 1-2 seconds on fresh install) try? await Task.sleep(for: .seconds(2)) - print("CloudSyncManager[\(T.gameIdentifier)]: Delayed check - forcing sync...") + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Delayed check - forcing sync...") // Force another sync (on main thread to avoid concurrency warning) var syncResult = false await MainActor.run { syncResult = iCloudStore.synchronize() } - print("CloudSyncManager[\(T.gameIdentifier)]: synchronize() returned \(syncResult)") + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: synchronize() returned \(syncResult)") // Check what's in the store let allKeys = iCloudStore.dictionaryRepresentation.keys - print("CloudSyncManager[\(T.gameIdentifier)]: iCloud store keys: \(Array(allKeys))") + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: iCloud store keys: \(Array(allKeys))") // Try loading cloud data again if let cloudData = loadCloud() { - print("CloudSyncManager[\(T.gameIdentifier)]: Found cloud data with \(cloudData.roundsPlayed) rounds") + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Found cloud data with \(cloudData.roundsPlayed) rounds") if cloudData.roundsPlayed > data.roundsPlayed { - print("CloudSyncManager[\(T.gameIdentifier)]: Cloud has more data, updating...") + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Cloud has more data, updating...") data = cloudData hasCompletedInitialSync = true onCloudDataReceived?(cloudData) @@ -160,11 +160,11 @@ public final class CloudSyncManager { userInfo: ["gameIdentifier": T.gameIdentifier] ) } else { - print("CloudSyncManager[\(T.gameIdentifier)]: Local data has same or more rounds") + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Local data has same or more rounds") } } else { hasCompletedInitialSync = true - print("CloudSyncManager[\(T.gameIdentifier)]: No cloud data found after delay") + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: No cloud data found after delay") } } } @@ -178,7 +178,7 @@ public final class CloudSyncManager { self.data = dataToSave guard let encoded = try? encoder.encode(dataToSave) else { - print("CloudSyncManager: Failed to encode game data") + CasinoDesign.debugLog("CloudSyncManager: Failed to encode game data") return } @@ -194,7 +194,7 @@ public final class CloudSyncManager { syncStatus = "Synced" } - print("CloudSyncManager[\(T.gameIdentifier)]: Saved (rounds: \(dataToSave.roundsPlayed))") + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Saved (rounds: \(dataToSave.roundsPlayed))") } /// Convenience to update and save in one call. @@ -216,31 +216,31 @@ public final class CloudSyncManager { switch (localData, cloudData) { case (nil, nil): - print("CloudSyncManager[\(T.gameIdentifier)]: No saved data, using empty") + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: No saved data, using empty") finalData = T.empty case (let local?, nil): - print("CloudSyncManager[\(T.gameIdentifier)]: Using local data") + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Using local data") finalData = local case (nil, let cloud?): - print("CloudSyncManager[\(T.gameIdentifier)]: Using iCloud data") + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Using iCloud data") finalData = cloud case (let local?, let cloud?): // Use whichever has more rounds played if cloud.roundsPlayed > local.roundsPlayed { - print("CloudSyncManager[\(T.gameIdentifier)]: Using iCloud (more progress: \(cloud.roundsPlayed) vs \(local.roundsPlayed))") + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Using iCloud (more progress: \(cloud.roundsPlayed) vs \(local.roundsPlayed))") finalData = cloud // Update local with cloud data if let encoded = try? encoder.encode(cloud) { UserDefaults.standard.set(encoded, forKey: localKey) } } else if local.lastModified > cloud.lastModified { - print("CloudSyncManager[\(T.gameIdentifier)]: Using local (newer: \(local.lastModified))") + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Using local (newer: \(local.lastModified))") finalData = local } else { - print("CloudSyncManager[\(T.gameIdentifier)]: Using local data") + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Using local data") finalData = local } } @@ -305,7 +305,7 @@ public final class CloudSyncManager { switch reason { case NSUbiquitousKeyValueStoreServerChange, NSUbiquitousKeyValueStoreInitialSyncChange: - print("CloudSyncManager[\(T.gameIdentifier)]: Data changed from another device") + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Data changed from another device") syncStatus = "Received update" // Reload and notify @@ -327,11 +327,11 @@ public final class CloudSyncManager { } case NSUbiquitousKeyValueStoreQuotaViolationChange: - print("CloudSyncManager[\(T.gameIdentifier)]: iCloud quota exceeded") + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: iCloud quota exceeded") syncStatus = "Storage full" case NSUbiquitousKeyValueStoreAccountChange: - print("CloudSyncManager[\(T.gameIdentifier)]: iCloud account changed") + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: iCloud account changed") syncStatus = "Account changed" // Reload with new account data = load() @@ -355,7 +355,7 @@ public final class CloudSyncManager { data = T.empty syncStatus = "Data cleared" - print("CloudSyncManager[\(T.gameIdentifier)]: All data cleared") + CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: All data cleared") } } diff --git a/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift b/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift index d996c87..e8c3cf3 100644 --- a/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift +++ b/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift @@ -10,6 +10,20 @@ import SwiftUI /// Shared design constants for casino game components. public enum CasinoDesign { + // MARK: - Debug + + /// Set to true to enable debug logging in CasinoKit. + public static let showDebugLogs = false + + /// Logs a message only in debug builds when `showDebugLogs` is enabled. + public static func debugLog(_ message: String) { + #if DEBUG + if showDebugLogs { + print(message) + } + #endif + } + // MARK: - Spacing public enum Spacing {