diff --git a/Blackjack/Blackjack/Engine/GameState.swift b/Blackjack/Blackjack/Engine/GameState.swift index 254cbec..42bece2 100644 --- a/Blackjack/Blackjack/Engine/GameState.swift +++ b/Blackjack/Blackjack/Engine/GameState.swift @@ -21,11 +21,13 @@ enum GamePhase: Equatable { /// Main game state manager. @Observable @MainActor -final class GameState { - // MARK: - Core State +final class GameState: SessionManagedGame { + // MARK: - SessionManagedGame + + typealias Stats = BlackjackStats /// Current player balance. - private(set) var balance: Int + var balance: Int /// Current game phase. private(set) var currentPhase: GamePhase = .betting @@ -108,29 +110,29 @@ final class GameState { /// The result of the last round. private(set) var lastRoundResult: RoundResult? - /// Round history for statistics. + /// Round history for current session statistics. private(set) var roundHistory: [RoundResult] = [] - // MARK: - Statistics (persisted) + // MARK: - Session Tracking (SessionManagedGame) - private(set) var totalWinnings: Int = 0 - private(set) var biggestWin: Int = 0 - private(set) var biggestLoss: Int = 0 - private(set) var blackjackCount: Int = 0 - private(set) var bustCount: Int = 0 - private(set) var totalPlayTime: TimeInterval = 0 + /// The currently active session. + var currentSession: BlackjackSession? - /// Per-style statistics (keyed by style rawValue). - private(set) var styleStats: [String: StyleStatistics] = [:] + /// History of completed sessions. + var sessionHistory: [BlackjackSession] = [] - // MARK: - Round Timing + /// Starting balance for new sessions (from settings). + var startingBalance: Int { settings.startingBalance } - /// When the current round started (for duration tracking). - private var roundStartTime: Date? + /// Current game style identifier. + var currentGameStyle: String { settings.gameStyle.rawValue } /// The bet amount for the current round (tracked for stats). private var roundBetAmount: Int = 0 + /// Whether a session end has been requested (shows confirmation). + var showEndSessionConfirmation: Bool = false + // MARK: - Persistence /// iCloud sync manager for game data. @@ -349,8 +351,8 @@ final class GameState { syncSoundSettings() loadSavedGame() - // Start timing for the first round (includes betting phase) - roundStartTime = Date() + // Ensure we have an active session + ensureActiveSession() } /// Syncs sound settings with SoundManager. @@ -371,79 +373,56 @@ final class GameState { private func loadSavedGame() { let data = persistence.load() self.balance = data.balance - self.totalWinnings = data.totalWinnings - self.biggestWin = data.biggestWin - self.biggestLoss = data.biggestLoss - self.blackjackCount = data.blackjackCount - self.bustCount = data.bustCount - self.totalPlayTime = data.totalPlayTime - self.styleStats = data.styleStats + self.currentSession = data.currentSession + self.sessionHistory = data.sessionHistory Design.debugLog("πŸ“‚ Loaded game data:") - Design.debugLog(" - totalPlayTime: \(data.totalPlayTime) seconds") - Design.debugLog(" - styleStats keys: \(data.styleStats.keys.joined(separator: ", "))") - for (key, stats) in data.styleStats { - Design.debugLog(" - \(key): rounds=\(stats.roundsPlayed), time=\(stats.totalPlayTime)s, totalBet=\(stats.totalBetAmount)") + Design.debugLog(" - balance: \(data.balance)") + Design.debugLog(" - currentSession: \(data.currentSession?.id.uuidString ?? "none")") + Design.debugLog(" - sessionHistory count: \(data.sessionHistory.count)") + if let session = data.currentSession { + Design.debugLog(" - current session rounds: \(session.roundsPlayed), duration: \(session.duration)s") } // Set up callback for when iCloud data arrives later persistence.onCloudDataReceived = { [weak self] newData in guard let self else { return } self.balance = newData.balance - self.totalWinnings = newData.totalWinnings - self.biggestWin = newData.biggestWin - self.biggestLoss = newData.biggestLoss - self.blackjackCount = newData.blackjackCount - self.bustCount = newData.bustCount - self.totalPlayTime = newData.totalPlayTime - self.styleStats = newData.styleStats + self.currentSession = newData.currentSession + self.sessionHistory = newData.sessionHistory } } /// Saves current game data to iCloud and local storage. - private func saveGameData() { - // Note: savedRounds are reconstructed from roundHistory with current style - // The actual round data with style is stored during completeRound() - let savedRounds: [SavedRoundResult] = roundHistory.map { result in - SavedRoundResult( - date: Date(), - gameStyle: settings.gameStyle.rawValue, - mainResult: result.mainHandResult.saveName, - hadSplit: result.hadSplit, - totalWinnings: result.totalWinnings, - roundDuration: 0 // Duration tracked separately in styleStats - ) + func saveGameData() { + // Update current session before saving + if var session = currentSession { + session.endingBalance = balance + // Keep session's game style in sync with current settings + session.gameStyle = settings.gameStyle.rawValue + currentSession = session } let data = BlackjackGameData( lastModified: Date(), balance: balance, - roundHistory: savedRounds, - styleStats: styleStats, - totalWinnings: totalWinnings, - biggestWin: biggestWin, - biggestLoss: biggestLoss, - blackjackCount: blackjackCount, - bustCount: bustCount, - totalPlayTime: totalPlayTime + currentSession: currentSession, + sessionHistory: sessionHistory ) persistence.save(data) + + Design.debugLog("πŸ’Ύ Saved game data - session rounds: \(currentSession?.roundsPlayed ?? 0)") } - /// Clears all saved data. + /// Clears all saved data and starts fresh. func clearAllData() { persistence.reset() balance = settings.startingBalance - totalWinnings = 0 - biggestWin = 0 - biggestLoss = 0 - blackjackCount = 0 - bustCount = 0 - totalPlayTime = 0 - styleStats = [:] + currentSession = nil + sessionHistory = [] roundHistory = [] - roundStartTime = nil roundBetAmount = 0 + startNewSession() newRound() } @@ -503,9 +482,9 @@ final class GameState { func deal() async { guard canDeal else { return } - // Track bet amount for statistics (roundStartTime was set when betting phase started) + // Track bet amount for statistics roundBetAmount = currentBet + perfectPairsBet + twentyOnePlusThreeBet - Design.debugLog("🎴 Deal started - roundBetAmount: \(roundBetAmount), time since round start: \(Date().timeIntervalSince(roundStartTime ?? Date()))s") + Design.debugLog("🎴 Deal started - roundBetAmount: \(roundBetAmount)") // Ensure enough cards for a full hand - reshuffle if needed if !engine.canDealNewHand { @@ -1062,73 +1041,52 @@ final class GameState { roundWinnings -= sideBetsTotal } - // Calculate round duration - let roundDuration: TimeInterval - if let startTime = roundStartTime { - roundDuration = Date().timeIntervalSince(startTime) - Design.debugLog("⏱️ Round duration: \(roundDuration) seconds (start: \(startTime))") - } else { - roundDuration = 0 - Design.debugLog("⚠️ roundStartTime was nil - duration is 0") - } - totalPlayTime += roundDuration - roundStartTime = nil - Design.debugLog("⏱️ Total play time now: \(totalPlayTime) seconds") - - // Update global statistics - totalWinnings += roundWinnings - if roundWinnings > biggestWin { - biggestWin = roundWinnings - } - if roundWinnings < biggestLoss { - biggestLoss = roundWinnings - } - if wasBlackjack { - blackjackCount += 1 - } - if hadBust { - bustCount += 1 - } - - // Determine if this round was a win, loss, push, or surrender for stats + // Determine round outcome for session stats let mainResult = playerHands.first?.result let isWin = mainResult?.isWin ?? false let isLoss = mainResult == .lose || mainResult == .bust let isPush = mainResult == .push let isSurrender = mainResult == .surrender + let hadDoubled = playerHands.contains { $0.isDoubledDown } + let hadSplitHands = playerHands.count > 1 - // Update per-style statistics - let styleKey = settings.gameStyle.rawValue - var stats = styleStats[styleKey] ?? StyleStatistics() - stats.roundsPlayed += 1 - stats.totalPlayTime += roundDuration - stats.totalWinnings += roundWinnings - stats.totalBetAmount += roundBetAmount - - Design.debugLog("πŸ“Š Style[\(styleKey)] stats update:") - Design.debugLog(" - roundBetAmount: \(roundBetAmount)") - Design.debugLog(" - stats.totalBetAmount: \(stats.totalBetAmount)") - Design.debugLog(" - stats.totalPlayTime: \(stats.totalPlayTime) seconds") - Design.debugLog(" - stats.roundsPlayed: \(stats.roundsPlayed)") - - if roundWinnings > stats.biggestWin { - stats.biggestWin = roundWinnings - } - if roundWinnings < stats.biggestLoss { - stats.biggestLoss = roundWinnings - } - if roundBetAmount > stats.biggestBet { - stats.biggestBet = roundBetAmount + // Determine the round outcome enum + let outcome: RoundOutcome + if isWin { + outcome = .win + } else if isPush || isSurrender { + outcome = .push // Surrender and push treated as push for session stats + } else { + outcome = .lose } - if isWin { stats.wins += 1 } - if isLoss { stats.losses += 1 } - if isPush { stats.pushes += 1 } - if isSurrender { stats.surrenders += 1 } - if wasBlackjack { stats.blackjacks += 1 } - if hadBust { stats.busts += 1 } + // Capture values for closure + let tookInsurance = insuranceBet > 0 + let wonInsurance = insResult == .insuranceWin - styleStats[styleKey] = stats + // Record round in session using CasinoKit protocol + recordSessionRound( + winnings: roundWinnings, + betAmount: roundBetAmount, + outcome: outcome + ) { stats in + // Update Blackjack-specific stats + if wasBlackjack { stats.blackjacks += 1 } + if hadBust { stats.busts += 1 } + if isSurrender { stats.surrenders += 1 } + if hadDoubled { stats.doubles += 1 } + if hadSplitHands { stats.splits += 1 } + if tookInsurance { + stats.insuranceTaken += 1 + if wonInsurance { + stats.insuranceWon += 1 + } + } + } + + Design.debugLog("πŸ“Š Session stats update:") + Design.debugLog(" - roundsPlayed: \(currentSession?.roundsPlayed ?? 0)") + Design.debugLog(" - duration: \(currentSession?.duration ?? 0) seconds") // Create round result with all hand results, per-hand winnings, and side bets let allHandResults = playerHands.map { $0.result ?? .lose } @@ -1229,13 +1187,23 @@ final class GameState { lastRoundResult = nil currentPhase = .betting - // Start timing for the new round (includes betting phase) - roundStartTime = Date() - Design.debugLog("🎰 New round started - roundStartTime: \(roundStartTime!)") - sound.play(.newRound) } + // MARK: - SessionManagedGame Implementation + + /// Resets game-specific state when starting a new session. + func resetForNewSession() { + roundHistory = [] + engine.reshuffle() + newRound() + } + + /// Aggregated Blackjack-specific stats from all sessions. + var aggregatedBlackjackStats: BlackjackStats { + allSessions.aggregatedBlackjackStats() + } + // MARK: - Game Reset /// Resets the entire game (keeps statistics). @@ -1243,8 +1211,10 @@ final class GameState { balance = settings.startingBalance roundHistory = [] engine.reshuffle() + startNewSession() newRound() saveGameData() } } + diff --git a/Blackjack/Blackjack/Models/BlackjackStats.swift b/Blackjack/Blackjack/Models/BlackjackStats.swift new file mode 100644 index 0000000..896b4fa --- /dev/null +++ b/Blackjack/Blackjack/Models/BlackjackStats.swift @@ -0,0 +1,107 @@ +// +// BlackjackStats.swift +// Blackjack +// +// Blackjack-specific statistics that conform to CasinoKit's GameSpecificStats. +// + +import Foundation +import SwiftUI +import CasinoKit + +/// Blackjack-specific session statistics. +/// Tracks blackjacks, busts, surrenders, doubles, and splits. +struct BlackjackStats: GameSpecificStats { + /// Number of blackjacks hit. + var blackjacks: Int = 0 + + /// Number of busted hands. + var busts: Int = 0 + + /// Number of surrendered hands. + var surrenders: Int = 0 + + /// Number of doubled down hands. + var doubles: Int = 0 + + /// Number of split hands. + var splits: Int = 0 + + /// Number of insurance bets taken. + var insuranceTaken: Int = 0 + + /// Number of insurance bets won. + var insuranceWon: Int = 0 + + // MARK: - GameSpecificStats + + init() {} + + /// Display items for the statistics UI. + var displayItems: [StatDisplayItem] { + [ + StatDisplayItem( + icon: "21.circle.fill", + iconColor: .yellow, + label: String(localized: "Blackjacks"), + value: "\(blackjacks)", + valueColor: .yellow + ), + StatDisplayItem( + icon: "flame.fill", + iconColor: .orange, + label: String(localized: "Busts"), + value: "\(busts)", + valueColor: .orange + ), + StatDisplayItem( + icon: "arrow.up.right.circle.fill", + iconColor: .purple, + label: String(localized: "Doubles"), + value: "\(doubles)", + valueColor: .purple + ), + StatDisplayItem( + icon: "arrow.triangle.branch", + iconColor: .blue, + label: String(localized: "Splits"), + value: "\(splits)", + valueColor: .blue + ), + StatDisplayItem( + icon: "flag.fill", + iconColor: .gray, + label: String(localized: "Surrenders"), + value: "\(surrenders)", + valueColor: .gray + ) + ] + } +} + +// MARK: - Aggregation Extension + +extension Array where Element == GameSession { + /// Aggregates Blackjack-specific stats from all sessions. + func aggregatedBlackjackStats() -> BlackjackStats { + var combined = BlackjackStats() + + for session in self { + combined.blackjacks += session.gameStats.blackjacks + combined.busts += session.gameStats.busts + combined.surrenders += session.gameStats.surrenders + combined.doubles += session.gameStats.doubles + combined.splits += session.gameStats.splits + combined.insuranceTaken += session.gameStats.insuranceTaken + combined.insuranceWon += session.gameStats.insuranceWon + } + + return combined + } +} + +// MARK: - Type Aliases for Convenience + +/// Blackjack session type alias. +typealias BlackjackSession = GameSession + diff --git a/Blackjack/Blackjack/Resources/Localizable.xcstrings b/Blackjack/Blackjack/Resources/Localizable.xcstrings index 67bf7eb..8c915be 100644 --- a/Blackjack/Blackjack/Resources/Localizable.xcstrings +++ b/Blackjack/Blackjack/Resources/Localizable.xcstrings @@ -399,6 +399,10 @@ } } }, + "$%lld" : { + "comment" : "The starting balance of a session, displayed in bold text.", + "isCommentAutoGenerated" : true + }, "1 Deck: Lowest house edge (~0.17%), rare to find." : { "localizations" : { "en" : { @@ -874,6 +878,10 @@ } } }, + "ALL TIME SUMMARY" : { + "comment" : "Title for a section in the statistics sheet that provides a summary of the user's overall performance over all time.", + "isCommentAutoGenerated" : true + }, "Allow doubling on split hands" : { "localizations" : { "en" : { @@ -1131,6 +1139,10 @@ } } }, + "BALANCE" : { + "comment" : "Title of a section in the session detail view that shows the user's starting and ending balances.", + "isCommentAutoGenerated" : true + }, "Basic Strategy" : { "localizations" : { "en" : { @@ -1229,6 +1241,10 @@ "comment" : "Label in the statistics sheet for the player's best single win.", "isCommentAutoGenerated" : true }, + "Best session" : { + "comment" : "A label describing the best session a user has played.", + "isCommentAutoGenerated" : true + }, "Bet 2x minimum" : { "comment" : "Betting recommendation based on a true count of 1.", "isCommentAutoGenerated" : true, @@ -2068,6 +2084,10 @@ } } }, + "Completed sessions will appear here." : { + "comment" : "A description below the label \"Your Session History\" in the StatisticsSheetView, explaining that completed sessions will be listed there.", + "isCommentAutoGenerated" : true + }, "Cost: $%lld (half your bet)" : { "localizations" : { "en" : { @@ -2159,6 +2179,9 @@ } } } + }, + "Current" : { + }, "Current bet $%lld" : { "comment" : "A hint that appears when a user taps on a side bet zone. The text varies depending on whether a bet is currently placed or not.", @@ -3043,6 +3066,10 @@ } } }, + "Doubles" : { + "comment" : "Label for a stat item in the statistics UI that shows the number of times a hand was doubled down.", + "isCommentAutoGenerated" : true + }, "Enable 'Card Count' in Settings to practice." : { "localizations" : { "en" : { @@ -3109,6 +3136,22 @@ } } }, + "End Session" : { + "comment" : "The text for a button that ends the current game session.", + "isCommentAutoGenerated" : true + }, + "End Session?" : { + "comment" : "A confirmation dialog title that asks if the user wants to end their current session.", + "isCommentAutoGenerated" : true + }, + "Ended manually" : { + "comment" : "A description of a session that ended manually (e.g. by the user closing the game).", + "isCommentAutoGenerated" : true + }, + "Ending balance" : { + "comment" : "A label displayed below the user's ending balance in the session detail view.", + "isCommentAutoGenerated" : true + }, "European" : { "localizations" : { "en" : { @@ -3309,6 +3352,10 @@ } } }, + "GAME STATS" : { + "comment" : "Title for a section in the statistics sheet dedicated to blackjack-specific statistics.", + "isCommentAutoGenerated" : true + }, "GAME STYLE" : { "localizations" : { "en" : { @@ -3430,7 +3477,7 @@ "Get closer to 21 than the dealer without going over" : { }, - "GLOBAL" : { + "Global" : { "comment" : "Title for the \"Global\" tab in the statistics sheet.", "isCommentAutoGenerated" : true }, @@ -3478,6 +3525,10 @@ } } }, + "Hands" : { + "comment" : "Label for the number of blackjack hands played in a session.", + "isCommentAutoGenerated" : true + }, "Hands played" : { "comment" : "A label describing the number of hands a player has played in a game.", "isCommentAutoGenerated" : true @@ -3572,6 +3623,10 @@ } } }, + "History" : { + "comment" : "Title of the statistics tab that shows the user's session history.", + "isCommentAutoGenerated" : true + }, "Hit" : { "localizations" : { "en" : { @@ -4194,6 +4249,10 @@ } } }, + "Losing sessions" : { + "comment" : "A label describing the number of sessions that the user lost.", + "isCommentAutoGenerated" : true + }, "Losses" : { "extractionState" : "stale", "localizations" : { @@ -4217,8 +4276,8 @@ } } }, - "Lost hands" : { - "comment" : "Label for a circle that shows the number of lost blackjack hands in the statistics sheet.", + "Lost" : { + "comment" : "Label for a game outcome circle indicating a loss.", "isCommentAutoGenerated" : true }, "Lower house edge" : { @@ -4474,7 +4533,6 @@ } }, "Net" : { - "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -4496,6 +4554,10 @@ } } }, + "Net result" : { + "comment" : "Label for a row in the \"Chips stats\" section of the session detail view, showing the net result of the session (i.e. the difference between the starting and ending balance).", + "isCommentAutoGenerated" : true + }, "Never" : { "localizations" : { "en" : { @@ -4563,6 +4625,10 @@ } } }, + "No Active Session" : { + "comment" : "A message displayed when there is no active blackjack session to display statistics for.", + "isCommentAutoGenerated" : true + }, "No Hand" : { "comment" : "Description of a 21+3 side bet outcome when there is no qualifying hand.", "isCommentAutoGenerated" : true, @@ -4654,6 +4720,10 @@ } } }, + "No Session History" : { + "comment" : "A message displayed when a user has no session history.", + "isCommentAutoGenerated" : true + }, "No surrender option." : { "localizations" : { "en" : { @@ -5189,6 +5259,10 @@ } } }, + "Push" : { + "comment" : "Label for the \"Push\" outcome in the game stats section of the statistics sheet.", + "isCommentAutoGenerated" : true + }, "PUSH" : { "localizations" : { "en" : { @@ -5235,10 +5309,6 @@ } } }, - "Pushed hands" : { - "comment" : "Label for the \"Pushed hands\" outcome circle in the statistics sheet.", - "isCommentAutoGenerated" : true - }, "Pushes" : { "extractionState" : "stale", "localizations" : { @@ -5262,6 +5332,10 @@ } } }, + "Ran out of chips" : { + "comment" : "A description of why a blackjack session ended when the player ran out of chips.", + "isCommentAutoGenerated" : true + }, "Re-split Aces" : { "localizations" : { "en" : { @@ -5604,6 +5678,10 @@ }, "Select a chip and tap the bet area" : { + }, + "SESSION PERFORMANCE" : { + "comment" : "Title of a section in the statistics sheet that shows performance metrics for individual sessions.", + "isCommentAutoGenerated" : true }, "SESSION SUMMARY" : { "extractionState" : "stale", @@ -5628,6 +5706,10 @@ } } }, + "Sessions" : { + "comment" : "Label for the number of blackjack game sessions.", + "isCommentAutoGenerated" : true + }, "Settings" : { "localizations" : { "en" : { @@ -6110,6 +6192,10 @@ } } }, + "Splits" : { + "comment" : "Label for the number of split hands in the statistics UI.", + "isCommentAutoGenerated" : true + }, "Stand" : { "localizations" : { "en" : { @@ -6247,8 +6333,16 @@ } } }, + "Start playing to begin tracking your session." : { + "comment" : "A description text displayed in the \"No Active Session\" view, explaining that the user needs to start playing to see their session statistics.", + "isCommentAutoGenerated" : true + }, "Start with $1,000 and play risk-free" : { + }, + "Starting balance" : { + "comment" : "A label for the starting balance in the Balance section of a session detail view.", + "isCommentAutoGenerated" : true }, "STARTING BALANCE" : { "localizations" : { @@ -6773,6 +6867,10 @@ } } }, + "Time" : { + "comment" : "Label for the duration of a blackjack game.", + "isCommentAutoGenerated" : true + }, "Total bet" : { "comment" : "Label for the total bet value in the Statistics Sheet.", "isCommentAutoGenerated" : true @@ -6987,8 +7085,11 @@ } } }, + "win rate" : { + "comment" : "A description of what \"win rate\" means in the context of a casino game.", + "isCommentAutoGenerated" : true + }, "Win Rate" : { - "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -7056,6 +7157,10 @@ } } }, + "Winning sessions" : { + "comment" : "A label describing the number of sessions the user has won.", + "isCommentAutoGenerated" : true + }, "Wins" : { "extractionState" : "stale", "localizations" : { @@ -7079,8 +7184,8 @@ } } }, - "Won hands" : { - "comment" : "Label for a circle that represents the number of hands the user has won in a statistics sheet.", + "Won" : { + "comment" : "Label for a game outcome circle that indicates a win.", "isCommentAutoGenerated" : true }, "Worst" : { @@ -7110,6 +7215,10 @@ "comment" : "Description of a chip stat row when displaying the worst loss.", "isCommentAutoGenerated" : true }, + "Worst session" : { + "comment" : "A label for the worst session's winnings in the statistics sheet.", + "isCommentAutoGenerated" : true + }, "Yes ($%lld)" : { "localizations" : { "en" : { @@ -7132,6 +7241,18 @@ } } }, + "You played %lld hands with a net result of %@. This session will be saved to your history." : { + "comment" : "A message that appears when a user ends a game session. It includes the number of hands played and the net result of the session.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You played %1$lld hands with a net result of %2$@. This session will be saved to your history." + } + } + } + }, "You've run out of chips!" : { "extractionState" : "stale", "localizations" : { diff --git a/Blackjack/Blackjack/Storage/BlackjackGameData.swift b/Blackjack/Blackjack/Storage/BlackjackGameData.swift index 2e4fdc6..766a057 100644 --- a/Blackjack/Blackjack/Storage/BlackjackGameData.swift +++ b/Blackjack/Blackjack/Storage/BlackjackGameData.swift @@ -8,68 +8,36 @@ import Foundation import CasinoKit -/// Saved round result for history. -struct SavedRoundResult: Codable, Equatable { - let date: Date - let gameStyle: String // "vegas", "atlantic", "european", "custom" - let mainResult: String // "blackjack", "win", "lose", "push", "bust", "surrender" - let hadSplit: Bool - let totalWinnings: Int - let roundDuration: TimeInterval // Duration in seconds -} - -/// Per-style statistics for tracking. -struct StyleStatistics: Codable, Equatable { - var roundsPlayed: Int = 0 - var wins: Int = 0 - var losses: Int = 0 - var pushes: Int = 0 - var blackjacks: Int = 0 - var busts: Int = 0 - var surrenders: Int = 0 - var totalWinnings: Int = 0 - var biggestWin: Int = 0 - var biggestLoss: Int = 0 - var totalPlayTime: TimeInterval = 0 // Cumulative seconds - var totalBetAmount: Int = 0 - var biggestBet: Int = 0 -} - /// Persistent game data that syncs to iCloud. -struct BlackjackGameData: PersistableGameData { +struct BlackjackGameData: PersistableGameData, SessionPersistable { static let gameIdentifier = "blackjack" - var roundsPlayed: Int { roundHistory.count } + var roundsPlayed: Int { + // Total rounds from all sessions + let historicalRounds = sessionHistory.reduce(0) { $0 + $1.roundsPlayed } + let currentRounds = currentSession?.roundsPlayed ?? 0 + return historicalRounds + currentRounds + } + var lastModified: Date static var empty: BlackjackGameData { BlackjackGameData( lastModified: Date(), balance: 10_000, - roundHistory: [], - styleStats: [:], - totalWinnings: 0, - biggestWin: 0, - biggestLoss: 0, - blackjackCount: 0, - bustCount: 0, - totalPlayTime: 0 + currentSession: nil, + sessionHistory: [] ) } + /// Current player balance. var balance: Int - var roundHistory: [SavedRoundResult] - /// Per-style statistics keyed by style rawValue. - var styleStats: [String: StyleStatistics] + /// The currently active session (nil if no session started). + var currentSession: BlackjackSession? - // Legacy global stats (kept for backward compatibility) - var totalWinnings: Int - var biggestWin: Int - var biggestLoss: Int - var blackjackCount: Int - var bustCount: Int - var totalPlayTime: TimeInterval + /// History of completed sessions. + var sessionHistory: [BlackjackSession] } /// Persistent settings data that syncs to iCloud. @@ -130,4 +98,3 @@ struct BlackjackSettingsData: PersistableGameData { var hapticsEnabled: Bool var soundVolume: Float } - diff --git a/Blackjack/Blackjack/Views/Game/GameTableView.swift b/Blackjack/Blackjack/Views/Game/GameTableView.swift index be51017..1a2837e 100644 --- a/Blackjack/Blackjack/Views/Game/GameTableView.swift +++ b/Blackjack/Blackjack/Views/Game/GameTableView.swift @@ -70,8 +70,10 @@ struct GameTableView: View { SettingsView(settings: settings, gameState: gameState) } .onChange(of: showSettings) { wasShowing, isShowing in - // When settings sheet dismisses, check if we should show welcome + // When settings sheet dismisses, sync session and check welcome if wasShowing && !isShowing { + // Sync current session with any settings changes (e.g., game style) + state.saveGameData() checkForWelcomeSheet() } } diff --git a/Blackjack/Blackjack/Views/Sheets/SettingsView.swift b/Blackjack/Blackjack/Views/Sheets/SettingsView.swift index 0bf8154..99343b2 100644 --- a/Blackjack/Blackjack/Views/Sheets/SettingsView.swift +++ b/Blackjack/Blackjack/Views/Sheets/SettingsView.swift @@ -283,7 +283,7 @@ struct SettingsView: View { .font(.system(size: Design.BaseFontSize.body)) .foregroundStyle(.white.opacity(Design.Opacity.medium)) - Text("\(state.roundsPlayed)") + Text("\(state.aggregatedStats.totalRoundsPlayed)") .font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded)) .foregroundStyle(.white) } @@ -295,11 +295,11 @@ struct SettingsView: View { .font(.system(size: Design.BaseFontSize.body)) .foregroundStyle(.white.opacity(Design.Opacity.medium)) - let winnings = state.totalWinnings + let winnings = state.aggregatedStats.totalWinnings Text(winnings >= 0 ? "+$\(winnings)" : "-$\(abs(winnings))") .font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded)) .foregroundStyle(winnings >= 0 ? .green : .red) - } + } } Divider() diff --git a/Blackjack/Blackjack/Views/Sheets/StatisticsSheetView.swift b/Blackjack/Blackjack/Views/Sheets/StatisticsSheetView.swift index 1d47afc..0a87fdd 100644 --- a/Blackjack/Blackjack/Views/Sheets/StatisticsSheetView.swift +++ b/Blackjack/Blackjack/Views/Sheets/StatisticsSheetView.swift @@ -2,198 +2,304 @@ // StatisticsSheetView.swift // Blackjack // -// Game statistics with Global and per-style tabs. +// Game statistics with session history and per-style stats. // import SwiftUI import CasinoKit struct StatisticsSheetView: View { - let state: GameState + @Bindable var state: GameState @Environment(\.dismiss) private var dismiss - @State private var selectedPage: Int = 0 - - /// All available statistics pages (Global + each style). - private var pages: [StatisticsPage] { - var result: [StatisticsPage] = [ - .global(computeGlobalStats()) - ] - - // Add pages for each style that has been played - for style in BlackjackStyle.allCases where style != .custom { - if let stats = state.styleStats[style.rawValue], stats.roundsPlayed > 0 { - result.append(.style(style, stats)) - } - } - - // Add custom if it has been played - if let customStats = state.styleStats[BlackjackStyle.custom.rawValue], customStats.roundsPlayed > 0 { - result.append(.style(.custom, customStats)) - } - - return result - } - - /// Computes aggregated global statistics from all styles. - private func computeGlobalStats() -> StyleStatistics { - var global = StyleStatistics() - - for (_, stats) in state.styleStats { - global.roundsPlayed += stats.roundsPlayed - global.wins += stats.wins - global.losses += stats.losses - global.pushes += stats.pushes - global.blackjacks += stats.blackjacks - global.busts += stats.busts - global.surrenders += stats.surrenders - global.totalWinnings += stats.totalWinnings - global.totalPlayTime += stats.totalPlayTime - global.totalBetAmount += stats.totalBetAmount - - if stats.biggestWin > global.biggestWin { - global.biggestWin = stats.biggestWin - } - if stats.biggestLoss < global.biggestLoss { - global.biggestLoss = stats.biggestLoss - } - if stats.biggestBet > global.biggestBet { - global.biggestBet = stats.biggestBet - } - } - - // If no style stats exist yet, use session data - if global.roundsPlayed == 0 { - global.roundsPlayed = state.roundHistory.count - global.wins = state.roundHistory.filter { $0.mainHandResult.isWin }.count - global.losses = state.roundHistory.filter { $0.mainHandResult == .lose || $0.mainHandResult == .bust }.count - global.pushes = state.roundHistory.filter { $0.mainHandResult == .push }.count - global.blackjacks = state.roundHistory.filter { $0.mainHandResult == .blackjack }.count - global.busts = state.roundHistory.filter { $0.mainHandResult == .bust }.count - global.surrenders = state.roundHistory.filter { $0.mainHandResult == .surrender }.count - global.totalWinnings = state.roundHistory.reduce(0) { $0 + $1.totalWinnings } - global.totalPlayTime = state.totalPlayTime - } - - return global - } + @State private var selectedTab: StatisticsTab = .current + @State private var selectedSession: BlackjackSession? var body: some View { SheetContainerView( title: String(localized: "Statistics"), content: { - VStack(spacing: Design.Spacing.medium) { - // Page selector with current style header - pageHeader - - // Page indicator dots - pageIndicator - } + // Tab selector + tabSelector - // Current page content - if selectedPage < pages.count { - statisticsContent(for: pages[selectedPage]) + // Content based on selected tab + switch selectedTab { + case .current: + currentSessionContent + case .global: + globalStatsContent + case .history: + sessionHistoryContent } }, onCancel: nil, onDone: { dismiss() }, doneButtonText: String(localized: "Done") ) - .gesture( - DragGesture() - .onEnded { value in - let threshold: CGFloat = 50 - if value.translation.width < -threshold && selectedPage < pages.count - 1 { - withAnimation(.spring(duration: Design.Animation.springDuration)) { - selectedPage += 1 - } - } else if value.translation.width > threshold && selectedPage > 0 { - withAnimation(.spring(duration: Design.Animation.springDuration)) { - selectedPage -= 1 - } - } - } - ) + .confirmationDialog( + String(localized: "End Session?"), + isPresented: $state.showEndSessionConfirmation, + titleVisibility: .visible + ) { + Button(String(localized: "End Session"), role: .destructive) { + state.endSessionAndStartNew() + dismiss() + } + Button(String(localized: "Cancel"), role: .cancel) {} + } message: { + if let session = state.currentSession { + Text(String(localized: "You played \(session.roundsPlayed) hands with a net result of \(SessionFormatter.formatMoney(session.netResult)). This session will be saved to your history.")) + } + } + .sheet(item: $selectedSession) { session in + SessionDetailView(session: session, styleDisplayName: styleDisplayName(for: session.gameStyle)) + } } - // MARK: - Page Header + // MARK: - Tab Selector + + private var tabSelector: some View { + HStack(spacing: 0) { + ForEach(StatisticsTab.allCases, id: \.self) { tab in + Button { + withAnimation(.spring(duration: Design.Animation.springDuration)) { + selectedTab = tab + } + } label: { + VStack(spacing: Design.Spacing.xSmall) { + Image(systemName: tab.icon) + .font(.system(size: Design.BaseFontSize.large)) + Text(tab.title) + .font(.system(size: Design.BaseFontSize.small, weight: .medium)) + } + .foregroundStyle(selectedTab == tab ? Color.Sheet.accent : .white.opacity(Design.Opacity.medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, Design.Spacing.medium) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .fill(selectedTab == tab ? Color.Sheet.accent.opacity(Design.Opacity.hint) : .clear) + ) + } + } + } + .padding(.horizontal) + } + + // MARK: - Current Session Content @ViewBuilder - private var pageHeader: some View { - let currentPage = selectedPage < pages.count ? pages[selectedPage] : .global(StyleStatistics()) - - HStack(spacing: Design.Spacing.medium) { - // Left arrow - Button { - withAnimation(.spring(duration: Design.Animation.springDuration)) { - if selectedPage > 0 { - selectedPage -= 1 - } + private var currentSessionContent: some View { + if let session = state.currentSession { + // Current session header + CurrentSessionHeader( + duration: session.duration, + roundsPlayed: session.roundsPlayed, + netResult: session.netResult, + onEndSession: { + state.showEndSessionConfirmation = true } - } label: { - Image(systemName: "chevron.left") - .font(.system(size: Design.BaseFontSize.large, weight: .semibold)) - .foregroundStyle(selectedPage > 0 ? Color.Sheet.accent : Color.white.opacity(Design.Opacity.hint)) - } - .disabled(selectedPage == 0) + ) + .padding(.horizontal) - Spacer() - - // Page title with icon - VStack(spacing: Design.Spacing.xSmall) { - Image(systemName: currentPage.icon) - .font(.system(size: Design.BaseFontSize.xxLarge)) - .foregroundStyle(currentPage.accentColor) - - Text(currentPage.title) - .font(.system(size: Design.BaseFontSize.title, weight: .bold, design: .rounded)) - .foregroundStyle(.white) - } - - Spacer() - - // Right arrow - Button { - withAnimation(.spring(duration: Design.Animation.springDuration)) { - if selectedPage < pages.count - 1 { - selectedPage += 1 - } - } - } label: { - Image(systemName: "chevron.right") - .font(.system(size: Design.BaseFontSize.large, weight: .semibold)) - .foregroundStyle(selectedPage < pages.count - 1 ? Color.Sheet.accent : Color.white.opacity(Design.Opacity.hint)) - } - .disabled(selectedPage >= pages.count - 1) + // Session stats + sessionStatsSection(session: session) + } else { + noActiveSessionView } - .padding(.horizontal, Design.Spacing.xLarge) - .padding(.top, Design.Spacing.small) } - // MARK: - Page Indicator + private var noActiveSessionView: some View { + VStack(spacing: Design.Spacing.large) { + Image(systemName: "play.slash") + .font(.system(size: Design.BaseFontSize.xxLarge * 2)) + .foregroundStyle(.white.opacity(Design.Opacity.light)) + + Text(String(localized: "No Active Session")) + .font(.system(size: Design.BaseFontSize.large, weight: .semibold)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + + Text(String(localized: "Start playing to begin tracking your session.")) + .font(.system(size: Design.BaseFontSize.body)) + .foregroundStyle(.white.opacity(Design.Opacity.light)) + .multilineTextAlignment(.center) + } + .padding(Design.Spacing.xxLarge) + } - private var pageIndicator: some View { - HStack(spacing: Design.Spacing.small) { - ForEach(pages.indices, id: \.self) { index in - Circle() - .fill(index == selectedPage ? Color.Sheet.accent : Color.white.opacity(Design.Opacity.light)) - .frame(width: Design.Spacing.small, height: Design.Spacing.small) - .onTapGesture { - withAnimation(.spring(duration: Design.Animation.springDuration)) { - selectedPage = index - } + // MARK: - Global Stats Content + + private var globalStatsContent: some View { + let stats = state.aggregatedStats + let gameStats = state.aggregatedBlackjackStats + + return Group { + // Summary section + SheetSection(title: String(localized: "ALL TIME SUMMARY"), icon: "globe") { + VStack(spacing: Design.Spacing.large) { + // Sessions overview + HStack(spacing: Design.Spacing.large) { + StatColumn( + value: "\(stats.totalSessions)", + label: String(localized: "Sessions") + ) + StatColumn( + value: "\(stats.totalRoundsPlayed)", + label: String(localized: "Hands") + ) + StatColumn( + value: SessionFormatter.formatPercent(stats.winRate), + label: String(localized: "Win Rate"), + valueColor: stats.winRate >= 50 ? .green : .orange + ) } + + Divider().background(Color.white.opacity(Design.Opacity.hint)) + + // Financial summary + HStack(spacing: Design.Spacing.large) { + StatColumn( + value: SessionFormatter.formatMoney(stats.totalWinnings), + label: String(localized: "Net"), + valueColor: stats.totalWinnings >= 0 ? .green : .red + ) + StatColumn( + value: SessionFormatter.formatDuration(stats.totalPlayTime), + label: String(localized: "Time") + ) + } + } + } + + // Blackjack-specific stats + SheetSection(title: String(localized: "GAME STATS"), icon: "suit.spade.fill") { + VStack(spacing: Design.Spacing.medium) { + ForEach(gameStats.displayItems) { item in + GameStatRow(item: item) + } + } + } + + // Session performance + SheetSection(title: String(localized: "SESSION PERFORMANCE"), icon: "chart.bar.fill") { + VStack(spacing: Design.Spacing.medium) { + HStack { + Text(String(localized: "Winning sessions")) + Spacer() + Text("\(stats.winningSessions)") + .foregroundStyle(.green) + .bold() + } + HStack { + Text(String(localized: "Losing sessions")) + Spacer() + Text("\(stats.losingSessions)") + .foregroundStyle(.red) + .bold() + } + + Divider().background(Color.white.opacity(Design.Opacity.hint)) + + HStack { + Text(String(localized: "Best session")) + Spacer() + Text(SessionFormatter.formatMoney(stats.bestSession)) + .foregroundStyle(.green) + .bold() + } + HStack { + Text(String(localized: "Worst session")) + Spacer() + Text(SessionFormatter.formatMoney(stats.worstSession)) + .foregroundStyle(.red) + .bold() + } + } + .font(.system(size: Design.BaseFontSize.body)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) } } } - // MARK: - Statistics Content + // MARK: - Session History Content + + private var sessionHistoryContent: some View { + Group { + if state.sessionHistory.isEmpty && state.currentSession == nil { + emptyHistoryView + } else { + LazyVStack(spacing: Design.Spacing.medium) { + // Current session at top if exists (taps go to Current tab) + if let current = state.currentSession { + Button { + withAnimation { + selectedTab = .current + } + } label: { + SessionSummaryRow( + styleDisplayName: styleDisplayName(for: current.gameStyle), + duration: current.duration, + roundsPlayed: current.roundsPlayed, + netResult: current.netResult, + startTime: current.startTime, + isActive: true, + endReason: nil + ) + } + .buttonStyle(.plain) + } + + // Historical sessions - tap to view details + ForEach(state.sessionHistory) { session in + Button { + selectedSession = session + } label: { + HStack { + SessionSummaryRow( + styleDisplayName: styleDisplayName(for: session.gameStyle), + duration: session.duration, + roundsPlayed: session.roundsPlayed, + netResult: session.netResult, + startTime: session.startTime, + isActive: false, + endReason: session.endReason + ) + + Image(systemName: "chevron.right") + .font(.system(size: Design.BaseFontSize.small, weight: .semibold)) + .foregroundStyle(.white.opacity(Design.Opacity.light)) + } + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + } + } + } + + private var emptyHistoryView: some View { + VStack(spacing: Design.Spacing.large) { + Image(systemName: "clock.arrow.circlepath") + .font(.system(size: Design.BaseFontSize.xxLarge * 2)) + .foregroundStyle(.white.opacity(Design.Opacity.light)) + + Text(String(localized: "No Session History")) + .font(.system(size: Design.BaseFontSize.large, weight: .semibold)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + + Text(String(localized: "Completed sessions will appear here.")) + .font(.system(size: Design.BaseFontSize.body)) + .foregroundStyle(.white.opacity(Design.Opacity.light)) + .multilineTextAlignment(.center) + } + .padding(Design.Spacing.xxLarge) + } + + // MARK: - Session Stats Section @ViewBuilder - private func statisticsContent(for page: StatisticsPage) -> some View { - let stats = page.statistics - - // In-Game Stats section + private func sessionStatsSection(session: BlackjackSession) -> some View { + // Game stats section SheetSection(title: String(localized: "IN GAME STATS"), icon: "gamecontroller.fill") { VStack(spacing: Design.Spacing.large) { // Hands played @@ -201,84 +307,68 @@ struct StatisticsSheetView: View { Text(String(localized: "Hands played")) .font(.system(size: Design.BaseFontSize.body)) .foregroundStyle(.white.opacity(Design.Opacity.strong)) - Text("\(stats.roundsPlayed)") + Text("\(session.roundsPlayed)") .font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded)) .foregroundStyle(.white) } - // Win/Loss/Push distribution + // Win/Loss/Push HStack(spacing: Design.Spacing.medium) { - OutcomeCircle( - label: String(localized: "Won hands"), - count: stats.wins, - color: .white - ) - OutcomeCircle( - label: String(localized: "Lost hands"), - count: stats.losses, - color: Color.red - ) - OutcomeCircle( - label: String(localized: "Pushed hands"), - count: stats.pushes, - color: .gray - ) + OutcomeCircle(label: String(localized: "Won"), count: session.wins, color: .white) + OutcomeCircle(label: String(localized: "Lost"), count: session.losses, color: .red) + OutcomeCircle(label: String(localized: "Push"), count: session.pushes, color: .gray) } - Divider() - .background(Color.white.opacity(Design.Opacity.hint)) + Divider().background(Color.white.opacity(Design.Opacity.hint)) - // Game time and special outcomes - StatRow(icon: "clock", label: String(localized: "Total game time"), value: formatTime(stats.totalPlayTime)) - StatRow(icon: "21.circle.fill", label: String(localized: "Blackjacks"), value: "\(stats.blackjacks)", valueColor: .yellow) - StatRow(icon: "flame.fill", label: String(localized: "Busts"), value: "\(stats.busts)", valueColor: .orange) + // Game time and Blackjack-specific stats + StatRow(icon: "clock", label: String(localized: "Total game time"), value: SessionFormatter.formatDuration(session.duration)) - if stats.surrenders > 0 { - StatRow(icon: "flag.fill", label: String(localized: "Surrenders"), value: "\(stats.surrenders)", valueColor: .gray) + ForEach(session.gameStats.displayItems) { item in + GameStatRow(item: item) } } } - // Chips Stats section + // Chips stats section SheetSection(title: String(localized: "CHIPS STATS"), icon: "dollarsign.circle.fill") { VStack(spacing: Design.Spacing.medium) { ChipStatRow( icon: "chart.line.uptrend.xyaxis", - iconColor: stats.totalWinnings >= 0 ? .green : .red, + iconColor: session.totalWinnings >= 0 ? .green : .red, label: String(localized: "Total gain"), - value: formatMoney(stats.totalWinnings) + value: SessionFormatter.formatMoney(session.totalWinnings) ) ChipStatRow( icon: "arrow.up.circle.fill", iconColor: .green, label: String(localized: "Best gain"), - value: formatMoney(stats.biggestWin) + value: SessionFormatter.formatMoney(session.biggestWin) ) ChipStatRow( icon: "arrow.down.circle.fill", iconColor: .red, label: String(localized: "Worst loss"), - value: formatMoney(stats.biggestLoss) + value: SessionFormatter.formatMoney(session.biggestLoss) ) - Divider() - .background(Color.white.opacity(Design.Opacity.hint)) + Divider().background(Color.white.opacity(Design.Opacity.hint)) ChipStatRow( icon: "plusminus.circle.fill", iconColor: .blue, label: String(localized: "Total bet"), - value: "$\(stats.totalBetAmount)" + value: "$\(session.totalBetAmount)" ) - if stats.roundsPlayed > 0 { + if session.roundsPlayed > 0 { ChipStatRow( icon: "equal.circle.fill", iconColor: .purple, label: String(localized: "Average bet"), - value: "$\(stats.totalBetAmount / stats.roundsPlayed)" + value: "$\(session.averageBet)" ) } @@ -286,101 +376,63 @@ struct StatisticsSheetView: View { icon: "star.circle.fill", iconColor: .orange, label: String(localized: "Biggest bet"), - value: "$\(stats.biggestBet)" + value: "$\(session.biggestBet)" ) } } } - // MARK: - Formatters + // MARK: - Helpers - private func formatMoney(_ amount: Int) -> String { - if amount >= 0 { - return "$\(amount)" - } else { - return "-$\(abs(amount))" - } - } - - private func formatTime(_ seconds: TimeInterval) -> String { - let hours = Int(seconds) / 3600 - let minutes = (Int(seconds) % 3600) / 60 - return String(format: "%02dh %02dmin", hours, minutes) + private func styleDisplayName(for rawValue: String) -> String { + BlackjackStyle(rawValue: rawValue)?.displayName ?? rawValue.capitalized } } -// MARK: - Statistics Page Type +// MARK: - Statistics Tab -private enum StatisticsPage: Identifiable { - case global(StyleStatistics) - case style(BlackjackStyle, StyleStatistics) - - var id: String { - switch self { - case .global: - return "global" - case .style(let style, _): - return style.rawValue - } - } +private enum StatisticsTab: CaseIterable { + case current + case global + case history var title: String { switch self { - case .global: - return String(localized: "GLOBAL") - case .style(let style, _): - return style.displayName.uppercased() + case .current: return String(localized: "Current") + case .global: return String(localized: "Global") + case .history: return String(localized: "History") } } var icon: String { switch self { - case .global: - return "globe" - case .style(let style, _): - switch style { - case .vegas: - return "building.2.fill" - case .atlantic: - return "water.waves" - case .european: - return "flag.fill" - case .custom: - return "slider.horizontal.3" - } - } - } - - var accentColor: Color { - switch self { - case .global: - return Color.Sheet.accent - case .style(let style, _): - switch style { - case .vegas: - return .orange - case .atlantic: - return .cyan - case .european: - return .blue - case .custom: - return .purple - } - } - } - - var statistics: StyleStatistics { - switch self { - case .global(let stats): - return stats - case .style(_, let stats): - return stats + case .current: return "play.circle.fill" + case .global: return "globe" + case .history: return "clock.arrow.circlepath" } } } // MARK: - Supporting Views +private struct StatColumn: View { + let value: String + let label: String + var valueColor: Color = .white + + var body: some View { + VStack(spacing: Design.Spacing.xSmall) { + Text(value) + .font(.system(size: Design.BaseFontSize.large, weight: .bold, design: .rounded)) + .foregroundStyle(valueColor) + Text(label) + .font(.system(size: Design.BaseFontSize.small)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + .frame(maxWidth: .infinity) + } +} + private struct OutcomeCircle: View { let label: String let count: Int @@ -405,7 +457,6 @@ private struct OutcomeCircle: View { .stroke(color, lineWidth: Design.LineWidth.thick) .frame(width: Size.outcomeCircleSize, height: Size.outcomeCircleSize) - // Inner filled circle Circle() .fill(color.opacity(Design.Opacity.medium)) .frame(width: Size.outcomeCircleInner, height: Size.outcomeCircleInner) @@ -449,7 +500,6 @@ private struct ChipStatRow: View { var body: some View { HStack { - // Chip-style icon ZStack { Circle() .fill(iconColor) @@ -473,6 +523,174 @@ private struct ChipStatRow: View { } } +// MARK: - Session Detail View + +private struct SessionDetailView: View { + let session: BlackjackSession + let styleDisplayName: String + + @Environment(\.dismiss) private var dismiss + + var body: some View { + SheetContainerView( + title: styleDisplayName, + content: { + // Session header info + sessionHeader + + // Game stats section + SheetSection(title: String(localized: "GAME STATS"), icon: "gamecontroller.fill") { + VStack(spacing: Design.Spacing.large) { + // Hands played + VStack(spacing: Design.Spacing.xSmall) { + Text(String(localized: "Hands played")) + .font(.system(size: Design.BaseFontSize.body)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + Text("\(session.roundsPlayed)") + .font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + } + + // Win/Loss/Push + HStack(spacing: Design.Spacing.medium) { + OutcomeCircle(label: String(localized: "Won"), count: session.wins, color: .white) + OutcomeCircle(label: String(localized: "Lost"), count: session.losses, color: .red) + OutcomeCircle(label: String(localized: "Push"), count: session.pushes, color: .gray) + } + + Divider().background(Color.white.opacity(Design.Opacity.hint)) + + // Game time + StatRow(icon: "clock", label: String(localized: "Total game time"), value: SessionFormatter.formatDuration(session.duration)) + + // Blackjack-specific stats + ForEach(session.gameStats.displayItems) { item in + GameStatRow(item: item) + } + } + } + + // Chips stats section + SheetSection(title: String(localized: "CHIPS STATS"), icon: "dollarsign.circle.fill") { + VStack(spacing: Design.Spacing.medium) { + ChipStatRow( + icon: "chart.line.uptrend.xyaxis", + iconColor: session.totalWinnings >= 0 ? .green : .red, + label: String(localized: "Net result"), + value: SessionFormatter.formatMoney(session.totalWinnings) + ) + + ChipStatRow( + icon: "arrow.up.circle.fill", + iconColor: .green, + label: String(localized: "Best gain"), + value: SessionFormatter.formatMoney(session.biggestWin) + ) + + ChipStatRow( + icon: "arrow.down.circle.fill", + iconColor: .red, + label: String(localized: "Worst loss"), + value: SessionFormatter.formatMoney(session.biggestLoss) + ) + + Divider().background(Color.white.opacity(Design.Opacity.hint)) + + ChipStatRow( + icon: "plusminus.circle.fill", + iconColor: .blue, + label: String(localized: "Total bet"), + value: "$\(session.totalBetAmount)" + ) + + if session.roundsPlayed > 0 { + ChipStatRow( + icon: "equal.circle.fill", + iconColor: .purple, + label: String(localized: "Average bet"), + value: "$\(session.averageBet)" + ) + } + + ChipStatRow( + icon: "star.circle.fill", + iconColor: .orange, + label: String(localized: "Biggest bet"), + value: "$\(session.biggestBet)" + ) + } + } + + // Balance section + SheetSection(title: String(localized: "BALANCE"), icon: "banknote.fill") { + VStack(spacing: Design.Spacing.medium) { + HStack { + Text(String(localized: "Starting balance")) + Spacer() + Text("$\(session.startingBalance)") + .bold() + } + HStack { + Text(String(localized: "Ending balance")) + Spacer() + Text("$\(session.endingBalance)") + .foregroundStyle(session.netResult >= 0 ? .green : .red) + .bold() + } + } + .font(.system(size: Design.BaseFontSize.body)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + } + }, + onCancel: nil, + onDone: { dismiss() }, + doneButtonText: String(localized: "Done") + ) + } + + private var sessionHeader: some View { + VStack(spacing: Design.Spacing.small) { + // Date and duration + HStack { + VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { + Text(SessionFormatter.formatSessionDate(session.startTime)) + .font(.system(size: Design.BaseFontSize.body, weight: .medium)) + .foregroundStyle(.white) + + if let endReason = session.endReason { + HStack(spacing: Design.Spacing.xSmall) { + Image(systemName: endReason == .brokeOut ? "xmark.circle.fill" : "checkmark.circle.fill") + .foregroundStyle(endReason == .brokeOut ? .red : .green) + Text(endReason == .brokeOut ? String(localized: "Ran out of chips") : String(localized: "Ended manually")) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + .font(.system(size: Design.BaseFontSize.small)) + } + } + + Spacer() + + // Net result badge + VStack(alignment: .trailing, spacing: Design.Spacing.xxSmall) { + Text(SessionFormatter.formatMoney(session.netResult)) + .font(.system(size: Design.BaseFontSize.xLarge, weight: .bold, design: .rounded)) + .foregroundStyle(session.netResult >= 0 ? .green : .red) + + Text(SessionFormatter.formatPercent(session.winRate) + " " + String(localized: "win rate")) + .font(.system(size: Design.BaseFontSize.small)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .fill(Color.Sheet.sectionFill) + ) + .padding(.horizontal) + } +} + // MARK: - Local Size Constants private enum Size { diff --git a/Blackjack/README.md b/Blackjack/README.md index bc8df19..4fa8fcc 100644 --- a/Blackjack/README.md +++ b/Blackjack/README.md @@ -56,11 +56,24 @@ Professional-grade tools for learning the Hi-Lo system: - **Betting Hints** β€” Recommendations based on count advantage - **Illustrious 18** β€” Count-adjusted strategy deviations -### πŸ“Š Statistics Tracking -- Win rate, blackjack count, bust rate -- Session net profit/loss -- Biggest wins and losses -- Complete round history +### πŸ“Š Session-Based Statistics +Track your play sessions like a real casino visit: + +- **Current Session** β€” Live stats for your active session +- **Global Stats** β€” Aggregated lifetime statistics +- **Session History** β€” Review past sessions with detailed breakdowns + +**Per-Session Tracking:** +- Duration and hands played +- Win/loss/push breakdown +- Net result and win rate +- Blackjacks, busts, doubles, splits +- Average and biggest bets + +**Session Management:** +- End a session manually or when you run out of chips +- Stats persisted across game styles (Vegas Strip, Atlantic City, etc.) +- Complete round history within each session ### ☁️ iCloud Sync - Balance and statistics sync across devices @@ -82,7 +95,7 @@ Blackjack/ β”‚ β”œβ”€β”€ Hand.swift # BlackjackHand model β”‚ └── SideBet.swift # Side bet types and evaluation β”œβ”€β”€ Storage/ -β”‚ └── BlackjackGameData.swift # Persistence models +β”‚ └── BlackjackGameData.swift # Persistence and session models β”œβ”€β”€ Theme/ β”‚ └── DesignConstants.swift # Design system tokens β”œβ”€β”€ Views/ diff --git a/CasinoKit/README.md b/CasinoKit/README.md index 61af56f..5dce363 100644 --- a/CasinoKit/README.md +++ b/CasinoKit/README.md @@ -479,6 +479,91 @@ sound.hapticError() // Error notification sound.hapticWarning() // Warning notification ``` +### πŸ“Š Session Management + +**GameSession** - Track play sessions with common and game-specific stats. + +```swift +// Create a game-specific stats type +struct MyGameStats: GameSpecificStats { + var specialWins: Int = 0 + + init() {} + + var displayItems: [StatDisplayItem] { + [StatDisplayItem(icon: "star.fill", iconColor: .yellow, + label: "Special Wins", value: "\(specialWins)")] + } +} + +// Create session type alias +typealias MyGameSession = GameSession +``` + +**SessionManagedGame Protocol** - Add session management to your game state. + +```swift +@Observable +class GameState: SessionManagedGame { + typealias Stats = MyGameStats + + var currentSession: MyGameSession? + var sessionHistory: [MyGameSession] = [] + + // Record round results + func completeRound(winnings: Int, bet: Int) { + recordSessionRound( + winnings: winnings, + betAmount: bet, + outcome: winnings > 0 ? .win : .lose + ) { stats in + if wasSpecialWin { + stats.specialWins += 1 + } + } + } +} +``` + +**Session UI Components:** + +```swift +// End session button +EndSessionButton { + state.showEndSessionConfirmation = true +} + +// Current session header with live stats +CurrentSessionHeader( + duration: session.duration, + roundsPlayed: session.roundsPlayed, + netResult: session.netResult, + onEndSession: { /* confirm */ } +) + +// Session row for history list +SessionSummaryRow( + styleDisplayName: "Vegas Strip", + duration: session.duration, + roundsPlayed: session.roundsPlayed, + netResult: session.netResult, + startTime: session.startTime, + isActive: false, + endReason: .manualEnd +) +``` + +**SessionFormatter** - Format session data for display. + +```swift +SessionFormatter.formatDuration(3600) // "01h 00min" +SessionFormatter.formatMoney(500) // "$500" +SessionFormatter.formatMoney(-250) // "-$250" +SessionFormatter.formatPercent(65.5) // "65.5%" +``` + +For detailed documentation, see [SESSION_SYSTEM.md](SESSION_SYSTEM.md). + ### πŸ’Ύ Cloud Storage **CloudSyncManager** - Saves game data locally and syncs with iCloud. @@ -681,6 +766,7 @@ CasinoKit/ β”œβ”€β”€ Package.swift β”œβ”€β”€ README.md β”œβ”€β”€ GAME_TEMPLATE.md # Guide for creating new games +β”œβ”€β”€ SESSION_SYSTEM.md # Session tracking documentation β”œβ”€β”€ Sources/CasinoKit/ β”‚ β”œβ”€β”€ CasinoKit.swift β”‚ β”œβ”€β”€ Exports.swift @@ -690,7 +776,11 @@ CasinoKit/ β”‚ β”‚ β”œβ”€β”€ ChipDenomination.swift β”‚ β”‚ β”œβ”€β”€ TableLimits.swift # Betting limit presets β”‚ β”‚ β”œβ”€β”€ OnboardingState.swift # Onboarding tracking -β”‚ β”‚ └── TooltipManager.swift # Tooltip management +β”‚ β”‚ β”œβ”€β”€ TooltipManager.swift # Tooltip management +β”‚ β”‚ └── Session/ +β”‚ β”‚ β”œβ”€β”€ GameSession.swift # Generic session with stats +β”‚ β”‚ β”œβ”€β”€ GameSessionProtocol.swift # Session protocols +β”‚ β”‚ └── SessionFormatter.swift # Formatting utilities β”‚ β”œβ”€β”€ Views/ β”‚ β”‚ β”œβ”€β”€ Cards/ β”‚ β”‚ β”‚ β”œβ”€β”€ CardView.swift @@ -724,8 +814,10 @@ CasinoKit/ β”‚ β”‚ β”‚ └── ActionButton.swift # Deal/Hit/Stand buttons β”‚ β”‚ β”œβ”€β”€ Zones/ β”‚ β”‚ β”‚ └── BettingZone.swift # Tappable betting area -β”‚ β”‚ └── Settings/ -β”‚ β”‚ └── SettingsComponents.swift # Toggle, pickers +β”‚ β”‚ β”œβ”€β”€ Settings/ +β”‚ β”‚ β”‚ └── SettingsComponents.swift # Toggle, pickers +β”‚ β”‚ └── Session/ +β”‚ β”‚ └── SessionViews.swift # Session UI components β”‚ β”œβ”€β”€ Audio/ β”‚ β”‚ └── SoundManager.swift β”‚ β”œβ”€β”€ Storage/ @@ -793,6 +885,13 @@ private var fontSize: CGFloat = CasinoDesign.BaseFontSize.body ## Version History +- **1.1.0** - Session system + - Generic `GameSession` for tracking play sessions + - `SessionManagedGame` protocol for easy integration + - Session UI components (header, summary rows, end button) + - `SessionFormatter` for consistent data display + - Aggregated statistics across sessions + - **1.0.0** - Initial release - Card and Chip components - Sheet container views diff --git a/CasinoKit/SESSION_SYSTEM.md b/CasinoKit/SESSION_SYSTEM.md new file mode 100644 index 0000000..0ff535e --- /dev/null +++ b/CasinoKit/SESSION_SYSTEM.md @@ -0,0 +1,409 @@ +# CasinoKit Session System + +This document describes the generic session tracking system in CasinoKit that can be used by any casino game. + +## Overview + +The session system provides: +- **Session tracking**: Track play sessions from start to end +- **Common statistics**: Wins, losses, pushes, bets, duration +- **Game-specific statistics**: Each game can track its own custom stats +- **Session history**: Store and display completed sessions +- **Aggregated statistics**: Combine stats across all sessions + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ CasinoKit β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ GameSession - Generic session with custom stats β”‚ +β”‚ GameSpecificStats - Protocol for game stats β”‚ +β”‚ SessionManagedGame - Protocol for game state classes β”‚ +β”‚ AggregatedSessionStats - Combined stats across sessions β”‚ +β”‚ SessionFormatter - Formatting utilities β”‚ +β”‚ UI Components - Reusable session UI views β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β” + β”‚ Blackjack β”‚ β”‚ Baccarat β”‚ + β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ + β”‚ BlackjackStatsβ”‚ β”‚ BaccaratStats β”‚ + β”‚ (implements β”‚ β”‚ (implements β”‚ + β”‚ GameSpecific) β”‚ β”‚ GameSpecific) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Core Types + +### GameSession + +A generic session that works with any game-specific stats type. + +```swift +public struct GameSession: Codable, Identifiable { + // Identity + let id: UUID + let gameStyle: String + + // Timing + let startTime: Date + var endTime: Date? + + // Balance + let startingBalance: Int + var endingBalance: Int + + // Common statistics (all games track these) + var roundsPlayed: Int + var wins: Int + var losses: Int + var pushes: Int + var totalWinnings: Int + var biggestWin: Int + var biggestLoss: Int + var totalBetAmount: Int + var biggestBet: Int + + // Game-specific statistics + var gameStats: Stats + + // Computed + var isActive: Bool + var duration: TimeInterval + var netResult: Int + var winRate: Double + var averageBet: Int +} +``` + +### GameSpecificStats Protocol + +Each game implements this to track game-specific statistics. + +```swift +public protocol GameSpecificStats: Codable, Equatable, Sendable { + init() + var displayItems: [StatDisplayItem] { get } +} +``` + +**Example: Blackjack Implementation** + +```swift +struct BlackjackStats: GameSpecificStats { + var blackjacks: Int = 0 + var busts: Int = 0 + var surrenders: Int = 0 + var doubles: Int = 0 + var splits: Int = 0 + var insuranceTaken: Int = 0 + var insuranceWon: Int = 0 + + var displayItems: [StatDisplayItem] { + [ + StatDisplayItem(icon: "21.circle.fill", iconColor: .yellow, + label: "Blackjacks", value: "\(blackjacks)"), + StatDisplayItem(icon: "flame.fill", iconColor: .orange, + label: "Busts", value: "\(busts)"), + // ... more items + ] + } +} + +typealias BlackjackSession = GameSession +``` + +**Example: Baccarat Implementation** + +```swift +struct BaccaratStats: GameSpecificStats { + var naturals: Int = 0 + var bankerWins: Int = 0 + var playerWins: Int = 0 + var ties: Int = 0 + var playerPairs: Int = 0 + var bankerPairs: Int = 0 + + var displayItems: [StatDisplayItem] { + [ + StatDisplayItem(icon: "sparkles", iconColor: .yellow, + label: "Naturals", value: "\(naturals)"), + // ... more items + ] + } +} + +typealias BaccaratSession = GameSession +``` + +### SessionManagedGame Protocol + +Game state classes conform to this to get session management. + +```swift +@MainActor +public protocol SessionManagedGame: AnyObject { + associatedtype Stats: GameSpecificStats + + var currentSession: GameSession? { get set } + var sessionHistory: [GameSession] { get set } + var balance: Int { get set } + var startingBalance: Int { get } + var currentGameStyle: String { get } + + func saveGameData() + func resetForNewSession() +} +``` + +**Default implementations provided:** +- `ensureActiveSession()` - Create session if needed +- `startNewSession()` - Start a fresh session +- `endCurrentSession(reason:)` - End and archive session +- `endSessionAndStartNew()` - End current and start new +- `handleSessionGameOver()` - Handle running out of money +- `recordSessionRound(...)` - Record a round result +- `aggregatedStats` - Get combined stats +- `sessions(forStyle:)` - Filter by game style + +## Integration Guide + +### Step 1: Create Game-Specific Stats + +```swift +// In your game's Models folder +struct MyGameStats: GameSpecificStats { + var customStat1: Int = 0 + var customStat2: Int = 0 + + init() {} + + var displayItems: [StatDisplayItem] { + [ + StatDisplayItem( + icon: "star.fill", + iconColor: .yellow, + label: "Custom Stat 1", + value: "\(customStat1)" + ), + // Add more as needed + ] + } +} + +typealias MyGameSession = GameSession +``` + +### Step 2: Update Game Data for Persistence + +```swift +struct MyGameData: PersistableGameData, SessionPersistable { + var currentSession: MyGameSession? + var sessionHistory: [MyGameSession] + var balance: Int + // ... other data +} +``` + +### Step 3: Conform GameState to SessionManagedGame + +```swift +@Observable +@MainActor +final class GameState: SessionManagedGame { + typealias Stats = MyGameStats + + var currentSession: MyGameSession? + var sessionHistory: [MyGameSession] = [] + var balance: Int + + var startingBalance: Int { settings.startingBalance } + var currentGameStyle: String { settings.gameStyle.rawValue } + + func saveGameData() { + // Persist to CloudSyncManager + } + + func resetForNewSession() { + // Reset game-specific state (reshuffle deck, clear history, etc.) + } + + init() { + // Load saved data... + ensureActiveSession() + } +} +``` + +### Step 4: Record Rounds + +```swift +// At the end of each round: +recordSessionRound( + winnings: roundWinnings, + betAmount: betAmount, + outcome: .win // or .lose, .push +) { stats in + // Update game-specific stats + if wasSpecialOutcome { + stats.customStat1 += 1 + } +} +``` + +### Step 5: Add Aggregation Extension (Optional) + +```swift +extension Array where Element == MyGameSession { + func aggregatedGameStats() -> MyGameStats { + var combined = MyGameStats() + for session in self { + combined.customStat1 += session.gameStats.customStat1 + combined.customStat2 += session.gameStats.customStat2 + } + return combined + } +} +``` + +## UI Components + +CasinoKit provides reusable UI components: + +### EndSessionButton +A styled button to trigger ending a session. + +```swift +EndSessionButton { + state.showEndSessionConfirmation = true +} +``` + +### EndSessionConfirmation +A confirmation dialog showing session summary. + +```swift +EndSessionConfirmation( + sessionDuration: session.duration, + netResult: session.netResult, + onConfirm: { state.endSessionAndStartNew() }, + onCancel: { dismiss() } +) +``` + +### CurrentSessionHeader +Header showing active session with end button. + +```swift +CurrentSessionHeader( + duration: session.duration, + roundsPlayed: session.roundsPlayed, + netResult: session.netResult, + onEndSession: { /* show confirmation */ } +) +``` + +### SessionSummaryRow +A row for displaying a session in a list. + +```swift +SessionSummaryRow( + styleDisplayName: "Vegas Strip", + duration: session.duration, + roundsPlayed: session.roundsPlayed, + netResult: session.netResult, + startTime: session.startTime, + isActive: session.isActive, + endReason: session.endReason +) +``` + +### GameStatRow +Display a single stat item from `displayItems`. + +```swift +ForEach(session.gameStats.displayItems) { item in + GameStatRow(item: item) +} +``` + +## SessionFormatter + +Utility for formatting session data: + +```swift +SessionFormatter.formatDuration(seconds) // "02h 15min" +SessionFormatter.formatMoney(amount) // "$500" or "-$250" +SessionFormatter.formatPercent(value) // "65.5%" +SessionFormatter.formatSessionDate(date) // "Dec 29, 2024, 10:30 AM" +SessionFormatter.formatRelativeDate(date) // "2h ago" +``` + +## Session Flow + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ App Launch │────▢│ Load Data │────▢│ Ensure β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ Session β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ ACTIVE SESSION β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Bet │────▢│ Play │────▢│ Record │──┐ β”‚ +β”‚ β”‚ β”‚ β”‚ Round β”‚ β”‚ Round β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β–² β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β–Ό β–Ό β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ End Session β”‚ β”‚ Game Over β”‚ β”‚ App Close β”‚ +β”‚ (Manual) β”‚ β”‚ (Broke) β”‚ β”‚ (Auto-save) β”‚ +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Session archived to history β”‚ +β”‚ New session can be started β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Best Practices + +1. **Always call `ensureActiveSession()` on init** - Guarantees a session exists. + +2. **Update balance in session after each round** - The `recordSessionRound` method handles this. + +3. **Implement `resetForNewSession()` properly** - Clear round history, reshuffle decks, reset UI state. + +4. **Use type aliases for convenience** - `typealias BlackjackSession = GameSession` + +5. **Provide aggregation extensions** - For combining game-specific stats across sessions. + +6. **Use `StatDisplayItem` for consistent UI** - Makes stats display automatic in UI components. + +## Data Storage + +Session data is stored via `CloudSyncManager` which handles: +- Local persistence to UserDefaults +- iCloud sync when available +- Conflict resolution using `lastModified` timestamps + +The `SessionPersistable` protocol extends `PersistableGameData` to add: +```swift +public protocol SessionPersistable { + associatedtype Stats: GameSpecificStats + var currentSession: GameSession? { get set } + var sessionHistory: [GameSession] { get set } +} +``` + diff --git a/CasinoKit/Sources/CasinoKit/Exports.swift b/CasinoKit/Sources/CasinoKit/Exports.swift index bfa0861..580ecac 100644 --- a/CasinoKit/Sources/CasinoKit/Exports.swift +++ b/CasinoKit/Sources/CasinoKit/Exports.swift @@ -91,6 +91,19 @@ // - CloudSyncManager // - PersistableGameData (protocol) +// MARK: - Sessions +// - GameSession (generic session with game-specific stats) +// - GameSpecificStats (protocol for game-specific statistics) +// - SessionManagedGame (protocol for games with session management) +// - SessionEndReason (.manualEnd, .brokeOut) +// - RoundOutcome (.win, .lose, .push) +// - AggregatedSessionStats (combined stats from multiple sessions) +// - StatDisplayItem (for displaying game-specific stats) +// - SessionFormatter (formatting utilities) +// - EndSessionButton, EndSessionConfirmation (UI components) +// - CurrentSessionHeader, SessionSummaryRow (UI components) +// - GameStatRow (display a stat item) + // MARK: - Debug // - debugBorder(_:color:label:) View modifier diff --git a/CasinoKit/Sources/CasinoKit/Models/Session/GameSessionProtocol.swift b/CasinoKit/Sources/CasinoKit/Models/Session/GameSessionProtocol.swift new file mode 100644 index 0000000..b7211ab --- /dev/null +++ b/CasinoKit/Sources/CasinoKit/Models/Session/GameSessionProtocol.swift @@ -0,0 +1,328 @@ +// +// GameSessionProtocol.swift +// CasinoKit +// +// Generic protocols for game sessions that work with any casino game. +// Each game (Blackjack, Baccarat, etc.) provides their own implementation. +// + +import Foundation +import SwiftUI + +// MARK: - Game-Specific Stats Protocol + +/// Protocol for game-specific statistics that each game implements. +/// Example: Blackjack tracks blackjacks, busts, surrenders. +/// Baccarat tracks naturals, banker wins, player wins. +public protocol GameSpecificStats: Codable, Equatable, Sendable { + /// Creates empty stats. + init() + + /// A list of stat items to display in the UI. + /// Each game returns its specific stats in a displayable format. + var displayItems: [StatDisplayItem] { get } +} + +/// A single stat item for display in the UI. +public struct StatDisplayItem: Identifiable, Sendable { + public let id = UUID() + public let icon: String + public let iconColor: Color + public let label: String + public let value: String + public let valueColor: Color + + public init( + icon: String, + iconColor: Color = .white, + label: String, + value: String, + valueColor: Color = .white + ) { + self.icon = icon + self.iconColor = iconColor + self.label = label + self.value = value + self.valueColor = valueColor + } +} + +// MARK: - Round Outcome + +/// Outcome of a single round - common to all casino games. +public enum RoundOutcome: String, Codable, Sendable { + case win + case lose + case push +} + +// MARK: - Session End Reason + +/// Reason why a session ended. +public enum SessionEndReason: String, Codable, Sendable { + case manualEnd = "ended" // Player chose to end session + case brokeOut = "broke" // Ran out of money +} + +// MARK: - Game Session + +/// A generic game session that works with any casino game. +/// The Stats type parameter allows each game to track game-specific statistics. +public struct GameSession: Codable, Identifiable, Equatable, Sendable { + + // MARK: - Identity + + /// Unique identifier for this session. + public let id: UUID + + /// The game style/variant used during this session (e.g., "vegas", "european"). + /// This is mutable to stay in sync with current settings. + public var gameStyle: String + + // MARK: - Timing + + /// When the session started. + public let startTime: Date + + /// When the session ended (nil if still active). + public var endTime: Date? + + // MARK: - Balance + + /// Balance at the start of the session. + public let startingBalance: Int + + /// Current/final balance for this session. + public var endingBalance: Int + + // MARK: - Common Statistics (all games track these) + + /// Number of rounds played in this session. + public var roundsPlayed: Int = 0 + + /// Number of winning rounds. + public var wins: Int = 0 + + /// Number of losing rounds. + public var losses: Int = 0 + + /// Number of pushed/tied rounds. + public var pushes: Int = 0 + + // MARK: - Financial Statistics + + /// Net winnings/losses for this session. + public var totalWinnings: Int = 0 + + /// Biggest single round win. + public var biggestWin: Int = 0 + + /// Biggest single round loss (stored as negative). + public var biggestLoss: Int = 0 + + /// Total amount bet across all rounds. + public var totalBetAmount: Int = 0 + + /// Largest single bet placed. + public var biggestBet: Int = 0 + + // MARK: - Game-Specific Statistics + + /// Game-specific statistics (Blackjack-specific, Baccarat-specific, etc.). + public var gameStats: Stats + + // MARK: - Computed Properties + + /// Whether this session is still active. + public var isActive: Bool { + endTime == nil + } + + /// Duration of the session in seconds. + public var duration: TimeInterval { + let end = endTime ?? Date() + return end.timeIntervalSince(startTime) + } + + /// Net result (ending balance - starting balance). + public var netResult: Int { + endingBalance - startingBalance + } + + /// Win rate as a percentage (0-100). + public var winRate: Double { + guard roundsPlayed > 0 else { return 0 } + return Double(wins) / Double(roundsPlayed) * 100 + } + + /// Average bet per round. + public var averageBet: Int { + guard roundsPlayed > 0 else { return 0 } + return totalBetAmount / roundsPlayed + } + + /// How the session ended. + public var endReason: SessionEndReason? { + guard !isActive else { return nil } + if endingBalance == 0 { + return .brokeOut + } + return .manualEnd + } + + // MARK: - Initialization + + /// Creates a new active session. + public init(gameStyle: String, startingBalance: Int) { + self.id = UUID() + self.gameStyle = gameStyle + self.startTime = Date() + self.endTime = nil + self.startingBalance = startingBalance + self.endingBalance = startingBalance + self.gameStats = Stats() + } + + // MARK: - Recording + + /// Records a round with the given outcome and updates stats. + public mutating func recordRound( + winnings: Int, + betAmount: Int, + outcome: RoundOutcome + ) { + roundsPlayed += 1 + totalWinnings += winnings + totalBetAmount += betAmount + + if winnings > biggestWin { + biggestWin = winnings + } + if winnings < biggestLoss { + biggestLoss = winnings + } + if betAmount > biggestBet { + biggestBet = betAmount + } + + switch outcome { + case .win: + wins += 1 + case .lose: + losses += 1 + case .push: + pushes += 1 + } + } + + /// Ends the session with the final balance. + public mutating func end(withBalance balance: Int) { + endTime = Date() + endingBalance = balance + } + + /// Updates the ending balance (call after each round). + public mutating func updateBalance(_ balance: Int) { + endingBalance = balance + } +} + +// MARK: - Aggregated Stats + +/// Aggregated statistics across multiple sessions. +/// Works with any game type. +public struct AggregatedSessionStats: Sendable { + public var totalSessions: Int = 0 + public var winningSessions: Int = 0 + public var losingSessions: Int = 0 + + public var totalRoundsPlayed: Int = 0 + public var totalWins: Int = 0 + public var totalLosses: Int = 0 + public var totalPushes: Int = 0 + + public var totalWinnings: Int = 0 + public var biggestWin: Int = 0 + public var biggestLoss: Int = 0 + public var bestSession: Int = 0 + public var worstSession: Int = 0 + + public var totalPlayTime: TimeInterval = 0 + public var totalBetAmount: Int = 0 + public var biggestBet: Int = 0 + + public var winRate: Double { + guard totalRoundsPlayed > 0 else { return 0 } + return Double(totalWins) / Double(totalRoundsPlayed) * 100 + } + + public var averageBet: Int { + guard totalRoundsPlayed > 0 else { return 0 } + return totalBetAmount / totalRoundsPlayed + } + + public var sessionWinRate: Double { + guard totalSessions > 0 else { return 0 } + return Double(winningSessions) / Double(totalSessions) * 100 + } + + public init() {} +} + +// MARK: - Array Extension for Aggregation + +extension Array { + /// Aggregates sessions into combined statistics. + public func aggregatedStats() -> AggregatedSessionStats + where Element == GameSession { + var stats = AggregatedSessionStats() + + for session in self { + stats.totalSessions += 1 + stats.totalRoundsPlayed += session.roundsPlayed + stats.totalWins += session.wins + stats.totalLosses += session.losses + stats.totalPushes += session.pushes + stats.totalWinnings += session.totalWinnings + stats.totalPlayTime += session.duration + stats.totalBetAmount += session.totalBetAmount + + if session.biggestWin > stats.biggestWin { + stats.biggestWin = session.biggestWin + } + if session.biggestLoss < stats.biggestLoss { + stats.biggestLoss = session.biggestLoss + } + if session.biggestBet > stats.biggestBet { + stats.biggestBet = session.biggestBet + } + + if session.netResult > 0 { + stats.winningSessions += 1 + if session.netResult > stats.bestSession { + stats.bestSession = session.netResult + } + } else if session.netResult < 0 { + stats.losingSessions += 1 + if session.netResult < stats.worstSession { + stats.worstSession = session.netResult + } + } + } + + return stats + } + + /// Aggregates game-specific stats from all sessions. + /// Games should provide their own aggregation logic. + public func aggregatedGameStats() -> Stats + where Element == GameSession { + var combined = Stats() + // Game-specific stats need custom aggregation - this returns the last session's stats + // Each game should implement their own aggregation extension + if let last = self.last { + combined = last.gameStats + } + return combined + } +} diff --git a/CasinoKit/Sources/CasinoKit/Models/Session/SessionManager.swift b/CasinoKit/Sources/CasinoKit/Models/Session/SessionManager.swift new file mode 100644 index 0000000..59460e8 --- /dev/null +++ b/CasinoKit/Sources/CasinoKit/Models/Session/SessionManager.swift @@ -0,0 +1,238 @@ +// +// SessionManager.swift +// CasinoKit +// +// Protocol and utilities for managing game sessions. +// Games conform to SessionManagedGame to get session management for free. +// + +import Foundation +import SwiftUI + +// MARK: - Session Managed Game Protocol + +/// Protocol for game state classes that use session management. +/// Conforming to this protocol gives you automatic session tracking. +/// +/// Example usage in GameState: +/// ```swift +/// @Observable +/// final class GameState: SessionManagedGame { +/// typealias Stats = BlackjackStats +/// var currentSession: GameSession? +/// var sessionHistory: [GameSession] = [] +/// // ... rest of implementation +/// } +/// ``` +@MainActor +public protocol SessionManagedGame: AnyObject { + /// The game-specific stats type. + associatedtype Stats: GameSpecificStats + + /// The currently active session (nil if no session). + var currentSession: GameSession? { get set } + + /// History of completed sessions. + var sessionHistory: [GameSession] { get set } + + /// Current player balance. + var balance: Int { get set } + + /// Starting balance for new sessions (from settings). + var startingBalance: Int { get } + + /// Current game style identifier (e.g., "vegas", "european"). + var currentGameStyle: String { get } + + /// Called to persist game data after session changes. + func saveGameData() + + /// Called when starting a new session to reset game-specific state. + func resetForNewSession() +} + +// MARK: - Default Session Management Implementation + +extension SessionManagedGame { + + /// Ensures there's an active session, creating one if needed. + public func ensureActiveSession() { + if currentSession == nil { + startNewSession() + } + } + + /// Starts a new session. + public func startNewSession() { + // End current session if exists + if currentSession != nil { + endCurrentSession(reason: .manualEnd) + } + + // Create new session with current settings + currentSession = GameSession( + gameStyle: currentGameStyle, + startingBalance: balance + ) + + saveGameData() + } + + /// Ends the current session and adds it to history. + public func endCurrentSession(reason: SessionEndReason = .manualEnd) { + guard var session = currentSession else { return } + + session.end(withBalance: balance) + + // Add to history (most recent first) + sessionHistory.insert(session, at: 0) + + currentSession = nil + saveGameData() + } + + /// Ends the current session and starts a fresh one. + public func endSessionAndStartNew() { + endCurrentSession(reason: .manualEnd) + + // Reset to starting balance + balance = startingBalance + + // Let game reset its specific state + resetForNewSession() + + // Start fresh session + startNewSession() + } + + /// Called when player runs out of money. + public func handleSessionGameOver() { + endCurrentSession(reason: .brokeOut) + } + + /// Records a round result in the current session. + /// Call this at the end of each round with the outcome. + public func recordSessionRound( + winnings: Int, + betAmount: Int, + outcome: RoundOutcome, + updateGameStats: ((inout Stats) -> Void)? = nil + ) { + guard var session = currentSession else { return } + + // Record common stats + session.recordRound( + winnings: winnings, + betAmount: betAmount, + outcome: outcome + ) + + // Let game update its specific stats + if let update = updateGameStats { + update(&session.gameStats) + } + + // Update balance + session.updateBalance(balance) + + currentSession = session + saveGameData() + } + + // MARK: - Computed Helpers + + /// Whether there's an active session. + public var hasActiveSession: Bool { + currentSession != nil + } + + /// All sessions including current. + public var allSessions: [GameSession] { + var sessions: [GameSession] = [] + if let current = currentSession { + sessions.append(current) + } + sessions.append(contentsOf: sessionHistory) + return sessions + } + + /// Aggregated stats from all sessions. + public var aggregatedStats: AggregatedSessionStats { + allSessions.aggregatedStats() + } + + /// Sessions filtered by game style. + public func sessions(forStyle style: String) -> [GameSession] { + allSessions.filter { $0.gameStyle == style } + } + + /// Aggregated stats for a specific style. + public func aggregatedStats(forStyle style: String) -> AggregatedSessionStats { + sessions(forStyle: style).aggregatedStats() + } +} + +// MARK: - Session Formatter + +/// Utility for formatting session data for display. +public enum SessionFormatter { + + /// Formats a duration as "XXh XXmin". + public static func formatDuration(_ seconds: TimeInterval) -> String { + let hours = Int(seconds) / 3600 + let minutes = (Int(seconds) % 3600) / 60 + return String(format: "%02dh %02dmin", hours, minutes) + } + + /// Formats a duration as "X hours, Y minutes" for accessibility. + public static func formatDurationAccessible(_ seconds: TimeInterval) -> String { + let hours = Int(seconds) / 3600 + let minutes = (Int(seconds) % 3600) / 60 + + if hours > 0 { + return "\(hours) hours, \(minutes) minutes" + } else { + return "\(minutes) minutes" + } + } + + /// Formats money with sign. + public static func formatMoney(_ amount: Int) -> String { + if amount >= 0 { + return "$\(amount)" + } else { + return "-$\(abs(amount))" + } + } + + /// Formats a date as relative time (e.g., "2 hours ago"). + public static func formatRelativeDate(_ date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) + } + + /// Formats a date for session display. + public static func formatSessionDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: date) + } + + /// Formats a percentage. + public static func formatPercent(_ value: Double) -> String { + value.formatted(.number.precision(.fractionLength(1))) + "%" + } +} + +// MARK: - Session Data Protocol + +/// Protocol for game data structs that store sessions. +/// Use this to define your PersistableGameData with session support. +public protocol SessionPersistable { + associatedtype Stats: GameSpecificStats + + var currentSession: GameSession? { get set } + var sessionHistory: [GameSession] { get set } +} diff --git a/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings b/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings index c08447d..38fab6a 100644 --- a/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings +++ b/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings @@ -334,6 +334,10 @@ } } }, + "ACTIVE" : { + "comment" : "A status label indicating that a session is currently active.", + "isCommentAutoGenerated" : true + }, "Add $%lld more to meet minimum" : { "comment" : "Hint shown when bet is below table minimum. The argument is the dollar amount needed.", "localizations" : { @@ -478,6 +482,14 @@ "comment" : "The accessibility label for the betting hint view.", "isCommentAutoGenerated" : true }, + "Bust" : { + "comment" : "A string describing when a player busts out of a game.", + "isCommentAutoGenerated" : true + }, + "Cancel" : { + "comment" : "A button label that cancels an action.", + "isCommentAutoGenerated" : true + }, "Card face down" : { "localizations" : { "en" : { @@ -659,6 +671,10 @@ } } }, + "Current Session" : { + "comment" : "A label for the header of the current session section.", + "isCommentAutoGenerated" : true + }, "Data Storage" : { "comment" : "Title of a section in the Privacy Policy View that discusses how game data is stored.", "isCommentAutoGenerated" : true, @@ -823,6 +839,10 @@ } } }, + "Duration" : { + "comment" : "A label displayed next to the duration of a session", + "isCommentAutoGenerated" : true + }, "Eight" : { "localizations" : { "en" : { @@ -867,6 +887,18 @@ } } }, + "End Session" : { + "comment" : "A button label that says \"End Session\".", + "isCommentAutoGenerated" : true + }, + "End Session?" : { + "comment" : "A title for the confirmation dialog that asks if the user is sure they want to end their session.", + "isCommentAutoGenerated" : true + }, + "Ended" : { + "comment" : "A label indicating that a session has ended.", + "isCommentAutoGenerated" : true + }, "Exclusive VIP room" : { "localizations" : { "en" : { @@ -1029,6 +1061,10 @@ }, "Got it" : { + }, + "Hands" : { + "comment" : "Label for the number of hands played in the current session.", + "isCommentAutoGenerated" : true }, "Hearts" : { "localizations" : { @@ -1425,6 +1461,13 @@ } } } + }, + "Net" : { + "comment" : "Label for the net result in the current session header.", + "isCommentAutoGenerated" : true + }, + "Net Result" : { + }, "New Round" : { "comment" : "A button label that initiates a new round of a casino game.", @@ -2273,6 +2316,10 @@ } } } + }, + "Your session will be saved to history and a new session will start." : { + "comment" : "A piece of information displayed in the confirmation dialog, explaining that the user's session will be saved and a new one will begin.", + "isCommentAutoGenerated" : true } }, "version" : "1.1" diff --git a/CasinoKit/Sources/CasinoKit/Views/Session/SessionViews.swift b/CasinoKit/Sources/CasinoKit/Views/Session/SessionViews.swift new file mode 100644 index 0000000..0cc2e01 --- /dev/null +++ b/CasinoKit/Sources/CasinoKit/Views/Session/SessionViews.swift @@ -0,0 +1,419 @@ +// +// SessionViews.swift +// CasinoKit +// +// Reusable UI components for session management. +// Works with any game that uses the session system. +// + +import SwiftUI + +// MARK: - End Session Button + +/// A button that triggers ending the current session. +public struct EndSessionButton: View { + let action: () -> Void + + public init(action: @escaping () -> Void) { + self.action = action + } + + public var body: some View { + Button { + action() + } label: { + HStack(spacing: CasinoDesign.Spacing.small) { + Image(systemName: "flag.checkered") + Text(String(localized: "End Session", bundle: .module)) + } + .font(.system(size: CasinoDesign.BaseFontSize.body, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, CasinoDesign.Spacing.large) + .padding(.vertical, CasinoDesign.Spacing.medium) + .background( + Capsule() + .fill(Color.orange.opacity(CasinoDesign.Opacity.strong)) + ) + } + } +} + +// MARK: - End Session Confirmation + +/// Confirmation dialog for ending a session. +public struct EndSessionConfirmation: View { + let sessionDuration: TimeInterval + let netResult: Int + let onConfirm: () -> Void + let onCancel: () -> Void + + public init( + sessionDuration: TimeInterval, + netResult: Int, + onConfirm: @escaping () -> Void, + onCancel: @escaping () -> Void + ) { + self.sessionDuration = sessionDuration + self.netResult = netResult + self.onConfirm = onConfirm + self.onCancel = onCancel + } + + public var body: some View { + VStack(spacing: CasinoDesign.Spacing.large) { + // Header + VStack(spacing: CasinoDesign.Spacing.small) { + Image(systemName: "flag.checkered.circle.fill") + .font(.system(size: CasinoDesign.BaseFontSize.xxLarge * 2)) + .foregroundStyle(.orange) + + Text(String(localized: "End Session?", bundle: .module)) + .font(.system(size: CasinoDesign.BaseFontSize.title, weight: .bold)) + .foregroundStyle(.white) + } + + // Session summary + VStack(spacing: CasinoDesign.Spacing.medium) { + HStack { + Text(String(localized: "Duration", bundle: .module)) + .foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium)) + Spacer() + Text(SessionFormatter.formatDuration(sessionDuration)) + .foregroundStyle(.white) + .bold() + } + + HStack { + Text(String(localized: "Net Result", bundle: .module)) + .foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium)) + Spacer() + Text(SessionFormatter.formatMoney(netResult)) + .foregroundStyle(netResult >= 0 ? .green : .red) + .bold() + } + } + .font(.system(size: CasinoDesign.BaseFontSize.body)) + .padding() + .background( + RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium) + .fill(Color.black.opacity(CasinoDesign.Opacity.light)) + ) + + // Info text + Text(String(localized: "Your session will be saved to history and a new session will start.", bundle: .module)) + .font(.system(size: CasinoDesign.BaseFontSize.small)) + .foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium)) + .multilineTextAlignment(.center) + + // Buttons + HStack(spacing: CasinoDesign.Spacing.medium) { + Button { + onCancel() + } label: { + Text(String(localized: "Cancel", bundle: .module)) + .font(.system(size: CasinoDesign.BaseFontSize.body, weight: .medium)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, CasinoDesign.Spacing.medium) + .background( + RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium) + .fill(Color.gray.opacity(CasinoDesign.Opacity.medium)) + ) + } + + Button { + onConfirm() + } label: { + Text(String(localized: "End Session", bundle: .module)) + .font(.system(size: CasinoDesign.BaseFontSize.body, weight: .bold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, CasinoDesign.Spacing.medium) + .background( + RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium) + .fill(Color.orange) + ) + } + } + } + .padding(CasinoDesign.Spacing.xLarge) + .background( + RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.xLarge) + .fill(Color.Sheet.background) + ) + .padding(CasinoDesign.Spacing.xLarge) + } +} + +// MARK: - Session Summary Row + +/// A compact row showing session summary - works with any game. +public struct SessionSummaryRow: View { + let styleDisplayName: String + let duration: TimeInterval + let roundsPlayed: Int + let netResult: Int + let startTime: Date + let isActive: Bool + let endReason: SessionEndReason? + + public init( + styleDisplayName: String, + duration: TimeInterval, + roundsPlayed: Int, + netResult: Int, + startTime: Date, + isActive: Bool, + endReason: SessionEndReason? + ) { + self.styleDisplayName = styleDisplayName + self.duration = duration + self.roundsPlayed = roundsPlayed + self.netResult = netResult + self.startTime = startTime + self.isActive = isActive + self.endReason = endReason + } + + private var resultColor: Color { + if netResult > 0 { return .green } + if netResult < 0 { return .red } + return .gray + } + + private var resultIcon: String { + if netResult > 0 { return "arrow.up.circle.fill" } + if netResult < 0 { return "arrow.down.circle.fill" } + return "equal.circle.fill" + } + + public var body: some View { + HStack(spacing: CasinoDesign.Spacing.medium) { + // Result indicator + Image(systemName: resultIcon) + .font(.system(size: CasinoDesign.BaseFontSize.xLarge)) + .foregroundStyle(resultColor) + + // Session info + VStack(alignment: .leading, spacing: CasinoDesign.Spacing.xxSmall) { + HStack { + Text(styleDisplayName) + .font(.system(size: CasinoDesign.BaseFontSize.body, weight: .semibold)) + .foregroundStyle(.white) + + if isActive { + Text(String(localized: "ACTIVE", bundle: .module)) + .font(.system(size: CasinoDesign.BaseFontSize.xxSmall, weight: .bold)) + .foregroundStyle(.green) + .padding(.horizontal, CasinoDesign.Spacing.xSmall) + .padding(.vertical, CasinoDesign.Spacing.xxSmall) + .background( + Capsule() + .fill(Color.green.opacity(CasinoDesign.Opacity.hint)) + ) + } + } + + HStack(spacing: CasinoDesign.Spacing.medium) { + Label( + SessionFormatter.formatDuration(duration), + systemImage: "clock" + ) + + Label( + "\(roundsPlayed)", + systemImage: "suit.spade.fill" + ) + } + .font(.system(size: CasinoDesign.BaseFontSize.small)) + .foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium)) + + Text(SessionFormatter.formatSessionDate(startTime)) + .font(.system(size: CasinoDesign.BaseFontSize.xxSmall)) + .foregroundStyle(.white.opacity(CasinoDesign.Opacity.light)) + } + + Spacer() + + // Net result + VStack(alignment: .trailing, spacing: CasinoDesign.Spacing.xxSmall) { + Text(SessionFormatter.formatMoney(netResult)) + .font(.system(size: CasinoDesign.BaseFontSize.large, weight: .bold, design: .rounded)) + .foregroundStyle(resultColor) + + if let reason = endReason { + Text(reason == .brokeOut + ? String(localized: "Bust", bundle: .module) + : String(localized: "Ended", bundle: .module)) + .font(.system(size: CasinoDesign.BaseFontSize.xxSmall)) + .foregroundStyle(.white.opacity(CasinoDesign.Opacity.light)) + } + } + } + .padding(CasinoDesign.Spacing.medium) + .background( + RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium) + .fill(Color.white.opacity(CasinoDesign.Opacity.subtle)) + ) + } +} + +// MARK: - Current Session Header + +/// A header showing the current active session with end button. +public struct CurrentSessionHeader: View { + let duration: TimeInterval + let roundsPlayed: Int + let netResult: Int + let onEndSession: () -> Void + + public init( + duration: TimeInterval, + roundsPlayed: Int, + netResult: Int, + onEndSession: @escaping () -> Void + ) { + self.duration = duration + self.roundsPlayed = roundsPlayed + self.netResult = netResult + self.onEndSession = onEndSession + } + + private var resultColor: Color { + if netResult > 0 { return .green } + if netResult < 0 { return .red } + return .white + } + + public var body: some View { + VStack(spacing: CasinoDesign.Spacing.medium) { + // Header row + HStack { + Label(String(localized: "Current Session", bundle: .module), systemImage: "play.circle.fill") + .font(.system(size: CasinoDesign.BaseFontSize.body, weight: .semibold)) + .foregroundStyle(.green) + + Spacer() + + EndSessionButton(action: onEndSession) + } + + Divider() + .background(Color.white.opacity(CasinoDesign.Opacity.hint)) + + // Stats row + HStack(spacing: CasinoDesign.Spacing.large) { + StatColumn( + value: SessionFormatter.formatDuration(duration), + label: String(localized: "Duration", bundle: .module) + ) + + StatColumn( + value: "\(roundsPlayed)", + label: String(localized: "Hands", bundle: .module) + ) + + StatColumn( + value: SessionFormatter.formatMoney(netResult), + label: String(localized: "Net", bundle: .module), + valueColor: resultColor + ) + } + } + .padding(CasinoDesign.Spacing.medium) + .background( + RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.large) + .fill(Color.green.opacity(CasinoDesign.Opacity.subtle)) + .overlay( + RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.large) + .stroke(Color.green.opacity(CasinoDesign.Opacity.light), lineWidth: CasinoDesign.LineWidth.thin) + ) + ) + } +} + +// MARK: - Stat Column (Helper) + +private struct StatColumn: View { + let value: String + let label: String + var valueColor: Color = .white + + var body: some View { + VStack(spacing: CasinoDesign.Spacing.xSmall) { + Text(value) + .font(.system(size: CasinoDesign.BaseFontSize.large, weight: .bold, design: .rounded)) + .foregroundStyle(valueColor) + Text(label) + .font(.system(size: CasinoDesign.BaseFontSize.small)) + .foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium)) + } + .frame(maxWidth: .infinity) + } +} + +// MARK: - Game Stats Display Row + +/// A row for displaying a single stat item. +public struct GameStatRow: View { + let item: StatDisplayItem + + public init(item: StatDisplayItem) { + self.item = item + } + + public var body: some View { + HStack { + // Icon + ZStack { + Circle() + .fill(item.iconColor) + .frame(width: CasinoDesign.Spacing.xLarge + CasinoDesign.Spacing.small, + height: CasinoDesign.Spacing.xLarge + CasinoDesign.Spacing.small) + + Image(systemName: item.icon) + .font(.system(size: CasinoDesign.BaseFontSize.small, weight: .bold)) + .foregroundStyle(.white) + } + + Text(item.label) + .font(.system(size: CasinoDesign.BaseFontSize.body)) + .foregroundStyle(.white.opacity(CasinoDesign.Opacity.strong)) + + Spacer() + + Text(item.value) + .font(.system(size: CasinoDesign.BaseFontSize.body, weight: .semibold, design: .rounded)) + .foregroundStyle(item.valueColor) + } + } +} + +// MARK: - Preview + +#Preview("End Session Confirmation") { + ZStack { + Color.black.ignoresSafeArea() + + EndSessionConfirmation( + sessionDuration: 3600 + 45 * 60, + netResult: -250, + onConfirm: {}, + onCancel: {} + ) + } +} + +#Preview("Current Session Header") { + ZStack { + Color.Sheet.background.ignoresSafeArea() + + CurrentSessionHeader( + duration: 1850, + roundsPlayed: 12, + netResult: 350, + onEndSession: {} + ) + .padding() + } +}