Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-12-29 13:03:03 -06:00
parent 982d54ed1d
commit 247435a405
15 changed files with 2415 additions and 464 deletions

View File

@ -21,11 +21,13 @@ enum GamePhase: Equatable {
/// Main game state manager. /// Main game state manager.
@Observable @Observable
@MainActor @MainActor
final class GameState { final class GameState: SessionManagedGame {
// MARK: - Core State // MARK: - SessionManagedGame
typealias Stats = BlackjackStats
/// Current player balance. /// Current player balance.
private(set) var balance: Int var balance: Int
/// Current game phase. /// Current game phase.
private(set) var currentPhase: GamePhase = .betting private(set) var currentPhase: GamePhase = .betting
@ -108,29 +110,29 @@ final class GameState {
/// The result of the last round. /// The result of the last round.
private(set) var lastRoundResult: RoundResult? private(set) var lastRoundResult: RoundResult?
/// Round history for statistics. /// Round history for current session statistics.
private(set) var roundHistory: [RoundResult] = [] private(set) var roundHistory: [RoundResult] = []
// MARK: - Statistics (persisted) // MARK: - Session Tracking (SessionManagedGame)
private(set) var totalWinnings: Int = 0 /// The currently active session.
private(set) var biggestWin: Int = 0 var currentSession: BlackjackSession?
private(set) var biggestLoss: Int = 0
private(set) var blackjackCount: Int = 0
private(set) var bustCount: Int = 0
private(set) var totalPlayTime: TimeInterval = 0
/// Per-style statistics (keyed by style rawValue). /// History of completed sessions.
private(set) var styleStats: [String: StyleStatistics] = [:] 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). /// Current game style identifier.
private var roundStartTime: Date? var currentGameStyle: String { settings.gameStyle.rawValue }
/// The bet amount for the current round (tracked for stats). /// The bet amount for the current round (tracked for stats).
private var roundBetAmount: Int = 0 private var roundBetAmount: Int = 0
/// Whether a session end has been requested (shows confirmation).
var showEndSessionConfirmation: Bool = false
// MARK: - Persistence // MARK: - Persistence
/// iCloud sync manager for game data. /// iCloud sync manager for game data.
@ -349,8 +351,8 @@ final class GameState {
syncSoundSettings() syncSoundSettings()
loadSavedGame() loadSavedGame()
// Start timing for the first round (includes betting phase) // Ensure we have an active session
roundStartTime = Date() ensureActiveSession()
} }
/// Syncs sound settings with SoundManager. /// Syncs sound settings with SoundManager.
@ -371,79 +373,56 @@ final class GameState {
private func loadSavedGame() { private func loadSavedGame() {
let data = persistence.load() let data = persistence.load()
self.balance = data.balance self.balance = data.balance
self.totalWinnings = data.totalWinnings self.currentSession = data.currentSession
self.biggestWin = data.biggestWin self.sessionHistory = data.sessionHistory
self.biggestLoss = data.biggestLoss
self.blackjackCount = data.blackjackCount
self.bustCount = data.bustCount
self.totalPlayTime = data.totalPlayTime
self.styleStats = data.styleStats
Design.debugLog("📂 Loaded game data:") Design.debugLog("📂 Loaded game data:")
Design.debugLog(" - totalPlayTime: \(data.totalPlayTime) seconds") Design.debugLog(" - balance: \(data.balance)")
Design.debugLog(" - styleStats keys: \(data.styleStats.keys.joined(separator: ", "))") Design.debugLog(" - currentSession: \(data.currentSession?.id.uuidString ?? "none")")
for (key, stats) in data.styleStats { Design.debugLog(" - sessionHistory count: \(data.sessionHistory.count)")
Design.debugLog(" - \(key): rounds=\(stats.roundsPlayed), time=\(stats.totalPlayTime)s, totalBet=\(stats.totalBetAmount)") 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 // Set up callback for when iCloud data arrives later
persistence.onCloudDataReceived = { [weak self] newData in persistence.onCloudDataReceived = { [weak self] newData in
guard let self else { return } guard let self else { return }
self.balance = newData.balance self.balance = newData.balance
self.totalWinnings = newData.totalWinnings self.currentSession = newData.currentSession
self.biggestWin = newData.biggestWin self.sessionHistory = newData.sessionHistory
self.biggestLoss = newData.biggestLoss
self.blackjackCount = newData.blackjackCount
self.bustCount = newData.bustCount
self.totalPlayTime = newData.totalPlayTime
self.styleStats = newData.styleStats
} }
} }
/// Saves current game data to iCloud and local storage. /// Saves current game data to iCloud and local storage.
private func saveGameData() { func saveGameData() {
// Note: savedRounds are reconstructed from roundHistory with current style // Update current session before saving
// The actual round data with style is stored during completeRound() if var session = currentSession {
let savedRounds: [SavedRoundResult] = roundHistory.map { result in session.endingBalance = balance
SavedRoundResult( // Keep session's game style in sync with current settings
date: Date(), session.gameStyle = settings.gameStyle.rawValue
gameStyle: settings.gameStyle.rawValue, currentSession = session
mainResult: result.mainHandResult.saveName,
hadSplit: result.hadSplit,
totalWinnings: result.totalWinnings,
roundDuration: 0 // Duration tracked separately in styleStats
)
} }
let data = BlackjackGameData( let data = BlackjackGameData(
lastModified: Date(), lastModified: Date(),
balance: balance, balance: balance,
roundHistory: savedRounds, currentSession: currentSession,
styleStats: styleStats, sessionHistory: sessionHistory
totalWinnings: totalWinnings,
biggestWin: biggestWin,
biggestLoss: biggestLoss,
blackjackCount: blackjackCount,
bustCount: bustCount,
totalPlayTime: totalPlayTime
) )
persistence.save(data) 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() { func clearAllData() {
persistence.reset() persistence.reset()
balance = settings.startingBalance balance = settings.startingBalance
totalWinnings = 0 currentSession = nil
biggestWin = 0 sessionHistory = []
biggestLoss = 0
blackjackCount = 0
bustCount = 0
totalPlayTime = 0
styleStats = [:]
roundHistory = [] roundHistory = []
roundStartTime = nil
roundBetAmount = 0 roundBetAmount = 0
startNewSession()
newRound() newRound()
} }
@ -503,9 +482,9 @@ final class GameState {
func deal() async { func deal() async {
guard canDeal else { return } 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 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 // Ensure enough cards for a full hand - reshuffle if needed
if !engine.canDealNewHand { if !engine.canDealNewHand {
@ -1062,73 +1041,52 @@ final class GameState {
roundWinnings -= sideBetsTotal roundWinnings -= sideBetsTotal
} }
// Calculate round duration // Determine round outcome for session stats
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
let mainResult = playerHands.first?.result let mainResult = playerHands.first?.result
let isWin = mainResult?.isWin ?? false let isWin = mainResult?.isWin ?? false
let isLoss = mainResult == .lose || mainResult == .bust let isLoss = mainResult == .lose || mainResult == .bust
let isPush = mainResult == .push let isPush = mainResult == .push
let isSurrender = mainResult == .surrender let isSurrender = mainResult == .surrender
let hadDoubled = playerHands.contains { $0.isDoubledDown }
let hadSplitHands = playerHands.count > 1
// Update per-style statistics // Determine the round outcome enum
let styleKey = settings.gameStyle.rawValue let outcome: RoundOutcome
var stats = styleStats[styleKey] ?? StyleStatistics() if isWin {
stats.roundsPlayed += 1 outcome = .win
stats.totalPlayTime += roundDuration } else if isPush || isSurrender {
stats.totalWinnings += roundWinnings outcome = .push // Surrender and push treated as push for session stats
stats.totalBetAmount += roundBetAmount } else {
outcome = .lose
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
} }
if isWin { stats.wins += 1 } // Capture values for closure
if isLoss { stats.losses += 1 } let tookInsurance = insuranceBet > 0
if isPush { stats.pushes += 1 } let wonInsurance = insResult == .insuranceWin
if isSurrender { stats.surrenders += 1 }
if wasBlackjack { stats.blackjacks += 1 }
if hadBust { stats.busts += 1 }
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 // Create round result with all hand results, per-hand winnings, and side bets
let allHandResults = playerHands.map { $0.result ?? .lose } let allHandResults = playerHands.map { $0.result ?? .lose }
@ -1229,13 +1187,23 @@ final class GameState {
lastRoundResult = nil lastRoundResult = nil
currentPhase = .betting currentPhase = .betting
// Start timing for the new round (includes betting phase)
roundStartTime = Date()
Design.debugLog("🎰 New round started - roundStartTime: \(roundStartTime!)")
sound.play(.newRound) 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 // MARK: - Game Reset
/// Resets the entire game (keeps statistics). /// Resets the entire game (keeps statistics).
@ -1243,8 +1211,10 @@ final class GameState {
balance = settings.startingBalance balance = settings.startingBalance
roundHistory = [] roundHistory = []
engine.reshuffle() engine.reshuffle()
startNewSession()
newRound() newRound()
saveGameData() saveGameData()
} }
} }

View File

@ -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<BlackjackStats> {
/// 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<BlackjackStats>

View File

@ -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." : { "1 Deck: Lowest house edge (~0.17%), rare to find." : {
"localizations" : { "localizations" : {
"en" : { "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" : { "Allow doubling on split hands" : {
"localizations" : { "localizations" : {
"en" : { "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" : { "Basic Strategy" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -1229,6 +1241,10 @@
"comment" : "Label in the statistics sheet for the player's best single win.", "comment" : "Label in the statistics sheet for the player's best single win.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Best session" : {
"comment" : "A label describing the best session a user has played.",
"isCommentAutoGenerated" : true
},
"Bet 2x minimum" : { "Bet 2x minimum" : {
"comment" : "Betting recommendation based on a true count of 1.", "comment" : "Betting recommendation based on a true count of 1.",
"isCommentAutoGenerated" : true, "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)" : { "Cost: $%lld (half your bet)" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -2159,6 +2179,9 @@
} }
} }
} }
},
"Current" : {
}, },
"Current bet $%lld" : { "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.", "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." : { "Enable 'Card Count' in Settings to practice." : {
"localizations" : { "localizations" : {
"en" : { "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" : { "European" : {
"localizations" : { "localizations" : {
"en" : { "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" : { "GAME STYLE" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -3430,7 +3477,7 @@
"Get closer to 21 than the dealer without going over" : { "Get closer to 21 than the dealer without going over" : {
}, },
"GLOBAL" : { "Global" : {
"comment" : "Title for the \"Global\" tab in the statistics sheet.", "comment" : "Title for the \"Global\" tab in the statistics sheet.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
@ -3478,6 +3525,10 @@
} }
} }
}, },
"Hands" : {
"comment" : "Label for the number of blackjack hands played in a session.",
"isCommentAutoGenerated" : true
},
"Hands played" : { "Hands played" : {
"comment" : "A label describing the number of hands a player has played in a game.", "comment" : "A label describing the number of hands a player has played in a game.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -3572,6 +3623,10 @@
} }
} }
}, },
"History" : {
"comment" : "Title of the statistics tab that shows the user's session history.",
"isCommentAutoGenerated" : true
},
"Hit" : { "Hit" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -4194,6 +4249,10 @@
} }
} }
}, },
"Losing sessions" : {
"comment" : "A label describing the number of sessions that the user lost.",
"isCommentAutoGenerated" : true
},
"Losses" : { "Losses" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@ -4217,8 +4276,8 @@
} }
} }
}, },
"Lost hands" : { "Lost" : {
"comment" : "Label for a circle that shows the number of lost blackjack hands in the statistics sheet.", "comment" : "Label for a game outcome circle indicating a loss.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Lower house edge" : { "Lower house edge" : {
@ -4474,7 +4533,6 @@
} }
}, },
"Net" : { "Net" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "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" : { "Never" : {
"localizations" : { "localizations" : {
"en" : { "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" : { "No Hand" : {
"comment" : "Description of a 21+3 side bet outcome when there is no qualifying hand.", "comment" : "Description of a 21+3 side bet outcome when there is no qualifying hand.",
"isCommentAutoGenerated" : true, "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." : { "No surrender option." : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -5189,6 +5259,10 @@
} }
} }
}, },
"Push" : {
"comment" : "Label for the \"Push\" outcome in the game stats section of the statistics sheet.",
"isCommentAutoGenerated" : true
},
"PUSH" : { "PUSH" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -5235,10 +5309,6 @@
} }
} }
}, },
"Pushed hands" : {
"comment" : "Label for the \"Pushed hands\" outcome circle in the statistics sheet.",
"isCommentAutoGenerated" : true
},
"Pushes" : { "Pushes" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "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" : { "Re-split Aces" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -5604,6 +5678,10 @@
}, },
"Select a chip and tap the bet area" : { "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" : { "SESSION SUMMARY" : {
"extractionState" : "stale", "extractionState" : "stale",
@ -5628,6 +5706,10 @@
} }
} }
}, },
"Sessions" : {
"comment" : "Label for the number of blackjack game sessions.",
"isCommentAutoGenerated" : true
},
"Settings" : { "Settings" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -6110,6 +6192,10 @@
} }
} }
}, },
"Splits" : {
"comment" : "Label for the number of split hands in the statistics UI.",
"isCommentAutoGenerated" : true
},
"Stand" : { "Stand" : {
"localizations" : { "localizations" : {
"en" : { "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" : { "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" : { "STARTING BALANCE" : {
"localizations" : { "localizations" : {
@ -6773,6 +6867,10 @@
} }
} }
}, },
"Time" : {
"comment" : "Label for the duration of a blackjack game.",
"isCommentAutoGenerated" : true
},
"Total bet" : { "Total bet" : {
"comment" : "Label for the total bet value in the Statistics Sheet.", "comment" : "Label for the total bet value in the Statistics Sheet.",
"isCommentAutoGenerated" : true "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" : { "Win Rate" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -7056,6 +7157,10 @@
} }
} }
}, },
"Winning sessions" : {
"comment" : "A label describing the number of sessions the user has won.",
"isCommentAutoGenerated" : true
},
"Wins" : { "Wins" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@ -7079,8 +7184,8 @@
} }
} }
}, },
"Won hands" : { "Won" : {
"comment" : "Label for a circle that represents the number of hands the user has won in a statistics sheet.", "comment" : "Label for a game outcome circle that indicates a win.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Worst" : { "Worst" : {
@ -7110,6 +7215,10 @@
"comment" : "Description of a chip stat row when displaying the worst loss.", "comment" : "Description of a chip stat row when displaying the worst loss.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Worst session" : {
"comment" : "A label for the worst session's winnings in the statistics sheet.",
"isCommentAutoGenerated" : true
},
"Yes ($%lld)" : { "Yes ($%lld)" : {
"localizations" : { "localizations" : {
"en" : { "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!" : { "You've run out of chips!" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {

View File

@ -8,68 +8,36 @@
import Foundation import Foundation
import CasinoKit 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. /// Persistent game data that syncs to iCloud.
struct BlackjackGameData: PersistableGameData { struct BlackjackGameData: PersistableGameData, SessionPersistable {
static let gameIdentifier = "blackjack" 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 var lastModified: Date
static var empty: BlackjackGameData { static var empty: BlackjackGameData {
BlackjackGameData( BlackjackGameData(
lastModified: Date(), lastModified: Date(),
balance: 10_000, balance: 10_000,
roundHistory: [], currentSession: nil,
styleStats: [:], sessionHistory: []
totalWinnings: 0,
biggestWin: 0,
biggestLoss: 0,
blackjackCount: 0,
bustCount: 0,
totalPlayTime: 0
) )
} }
/// Current player balance.
var balance: Int var balance: Int
var roundHistory: [SavedRoundResult]
/// Per-style statistics keyed by style rawValue. /// The currently active session (nil if no session started).
var styleStats: [String: StyleStatistics] var currentSession: BlackjackSession?
// Legacy global stats (kept for backward compatibility) /// History of completed sessions.
var totalWinnings: Int var sessionHistory: [BlackjackSession]
var biggestWin: Int
var biggestLoss: Int
var blackjackCount: Int
var bustCount: Int
var totalPlayTime: TimeInterval
} }
/// Persistent settings data that syncs to iCloud. /// Persistent settings data that syncs to iCloud.
@ -130,4 +98,3 @@ struct BlackjackSettingsData: PersistableGameData {
var hapticsEnabled: Bool var hapticsEnabled: Bool
var soundVolume: Float var soundVolume: Float
} }

View File

@ -70,8 +70,10 @@ struct GameTableView: View {
SettingsView(settings: settings, gameState: gameState) SettingsView(settings: settings, gameState: gameState)
} }
.onChange(of: showSettings) { wasShowing, isShowing in .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 { if wasShowing && !isShowing {
// Sync current session with any settings changes (e.g., game style)
state.saveGameData()
checkForWelcomeSheet() checkForWelcomeSheet()
} }
} }

View File

@ -283,7 +283,7 @@ struct SettingsView: View {
.font(.system(size: Design.BaseFontSize.body)) .font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium)) .foregroundStyle(.white.opacity(Design.Opacity.medium))
Text("\(state.roundsPlayed)") Text("\(state.aggregatedStats.totalRoundsPlayed)")
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded)) .font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
.foregroundStyle(.white) .foregroundStyle(.white)
} }
@ -295,11 +295,11 @@ struct SettingsView: View {
.font(.system(size: Design.BaseFontSize.body)) .font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium)) .foregroundStyle(.white.opacity(Design.Opacity.medium))
let winnings = state.totalWinnings let winnings = state.aggregatedStats.totalWinnings
Text(winnings >= 0 ? "+$\(winnings)" : "-$\(abs(winnings))") Text(winnings >= 0 ? "+$\(winnings)" : "-$\(abs(winnings))")
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded)) .font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
.foregroundStyle(winnings >= 0 ? .green : .red) .foregroundStyle(winnings >= 0 ? .green : .red)
} }
} }
Divider() Divider()

View File

@ -2,198 +2,304 @@
// StatisticsSheetView.swift // StatisticsSheetView.swift
// Blackjack // Blackjack
// //
// Game statistics with Global and per-style tabs. // Game statistics with session history and per-style stats.
// //
import SwiftUI import SwiftUI
import CasinoKit import CasinoKit
struct StatisticsSheetView: View { struct StatisticsSheetView: View {
let state: GameState @Bindable var state: GameState
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var selectedPage: Int = 0 @State private var selectedTab: StatisticsTab = .current
@State private var selectedSession: BlackjackSession?
/// 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
}
var body: some View { var body: some View {
SheetContainerView( SheetContainerView(
title: String(localized: "Statistics"), title: String(localized: "Statistics"),
content: { content: {
VStack(spacing: Design.Spacing.medium) { // Tab selector
// Page selector with current style header tabSelector
pageHeader
// Page indicator dots
pageIndicator
}
// Current page content // Content based on selected tab
if selectedPage < pages.count { switch selectedTab {
statisticsContent(for: pages[selectedPage]) case .current:
currentSessionContent
case .global:
globalStatsContent
case .history:
sessionHistoryContent
} }
}, },
onCancel: nil, onCancel: nil,
onDone: { dismiss() }, onDone: { dismiss() },
doneButtonText: String(localized: "Done") doneButtonText: String(localized: "Done")
) )
.gesture( .confirmationDialog(
DragGesture() String(localized: "End Session?"),
.onEnded { value in isPresented: $state.showEndSessionConfirmation,
let threshold: CGFloat = 50 titleVisibility: .visible
if value.translation.width < -threshold && selectedPage < pages.count - 1 { ) {
withAnimation(.spring(duration: Design.Animation.springDuration)) { Button(String(localized: "End Session"), role: .destructive) {
selectedPage += 1 state.endSessionAndStartNew()
} dismiss()
} else if value.translation.width > threshold && selectedPage > 0 { }
withAnimation(.spring(duration: Design.Animation.springDuration)) { Button(String(localized: "Cancel"), role: .cancel) {}
selectedPage -= 1 } 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 @ViewBuilder
private var pageHeader: some View { private var currentSessionContent: some View {
let currentPage = selectedPage < pages.count ? pages[selectedPage] : .global(StyleStatistics()) if let session = state.currentSession {
// Current session header
HStack(spacing: Design.Spacing.medium) { CurrentSessionHeader(
// Left arrow duration: session.duration,
Button { roundsPlayed: session.roundsPlayed,
withAnimation(.spring(duration: Design.Animation.springDuration)) { netResult: session.netResult,
if selectedPage > 0 { onEndSession: {
selectedPage -= 1 state.showEndSessionConfirmation = true
}
} }
} label: { )
Image(systemName: "chevron.left") .padding(.horizontal)
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
.foregroundStyle(selectedPage > 0 ? Color.Sheet.accent : Color.white.opacity(Design.Opacity.hint))
}
.disabled(selectedPage == 0)
Spacer() // Session stats
sessionStatsSection(session: session)
// Page title with icon } else {
VStack(spacing: Design.Spacing.xSmall) { noActiveSessionView
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)
} }
.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 { // MARK: - Global Stats Content
HStack(spacing: Design.Spacing.small) {
ForEach(pages.indices, id: \.self) { index in private var globalStatsContent: some View {
Circle() let stats = state.aggregatedStats
.fill(index == selectedPage ? Color.Sheet.accent : Color.white.opacity(Design.Opacity.light)) let gameStats = state.aggregatedBlackjackStats
.frame(width: Design.Spacing.small, height: Design.Spacing.small)
.onTapGesture { return Group {
withAnimation(.spring(duration: Design.Animation.springDuration)) { // Summary section
selectedPage = index 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 @ViewBuilder
private func statisticsContent(for page: StatisticsPage) -> some View { private func sessionStatsSection(session: BlackjackSession) -> some View {
let stats = page.statistics // Game stats section
// In-Game Stats section
SheetSection(title: String(localized: "IN GAME STATS"), icon: "gamecontroller.fill") { SheetSection(title: String(localized: "IN GAME STATS"), icon: "gamecontroller.fill") {
VStack(spacing: Design.Spacing.large) { VStack(spacing: Design.Spacing.large) {
// Hands played // Hands played
@ -201,84 +307,68 @@ struct StatisticsSheetView: View {
Text(String(localized: "Hands played")) Text(String(localized: "Hands played"))
.font(.system(size: Design.BaseFontSize.body)) .font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.strong)) .foregroundStyle(.white.opacity(Design.Opacity.strong))
Text("\(stats.roundsPlayed)") Text("\(session.roundsPlayed)")
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded)) .font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
.foregroundStyle(.white) .foregroundStyle(.white)
} }
// Win/Loss/Push distribution // Win/Loss/Push
HStack(spacing: Design.Spacing.medium) { HStack(spacing: Design.Spacing.medium) {
OutcomeCircle( OutcomeCircle(label: String(localized: "Won"), count: session.wins, color: .white)
label: String(localized: "Won hands"), OutcomeCircle(label: String(localized: "Lost"), count: session.losses, color: .red)
count: stats.wins, OutcomeCircle(label: String(localized: "Push"), count: session.pushes, color: .gray)
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
)
} }
Divider() Divider().background(Color.white.opacity(Design.Opacity.hint))
.background(Color.white.opacity(Design.Opacity.hint))
// Game time and special outcomes // Game time and Blackjack-specific stats
StatRow(icon: "clock", label: String(localized: "Total game time"), value: formatTime(stats.totalPlayTime)) StatRow(icon: "clock", label: String(localized: "Total game time"), value: SessionFormatter.formatDuration(session.duration))
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)
if stats.surrenders > 0 { ForEach(session.gameStats.displayItems) { item in
StatRow(icon: "flag.fill", label: String(localized: "Surrenders"), value: "\(stats.surrenders)", valueColor: .gray) GameStatRow(item: item)
} }
} }
} }
// Chips Stats section // Chips stats section
SheetSection(title: String(localized: "CHIPS STATS"), icon: "dollarsign.circle.fill") { SheetSection(title: String(localized: "CHIPS STATS"), icon: "dollarsign.circle.fill") {
VStack(spacing: Design.Spacing.medium) { VStack(spacing: Design.Spacing.medium) {
ChipStatRow( ChipStatRow(
icon: "chart.line.uptrend.xyaxis", icon: "chart.line.uptrend.xyaxis",
iconColor: stats.totalWinnings >= 0 ? .green : .red, iconColor: session.totalWinnings >= 0 ? .green : .red,
label: String(localized: "Total gain"), label: String(localized: "Total gain"),
value: formatMoney(stats.totalWinnings) value: SessionFormatter.formatMoney(session.totalWinnings)
) )
ChipStatRow( ChipStatRow(
icon: "arrow.up.circle.fill", icon: "arrow.up.circle.fill",
iconColor: .green, iconColor: .green,
label: String(localized: "Best gain"), label: String(localized: "Best gain"),
value: formatMoney(stats.biggestWin) value: SessionFormatter.formatMoney(session.biggestWin)
) )
ChipStatRow( ChipStatRow(
icon: "arrow.down.circle.fill", icon: "arrow.down.circle.fill",
iconColor: .red, iconColor: .red,
label: String(localized: "Worst loss"), label: String(localized: "Worst loss"),
value: formatMoney(stats.biggestLoss) value: SessionFormatter.formatMoney(session.biggestLoss)
) )
Divider() Divider().background(Color.white.opacity(Design.Opacity.hint))
.background(Color.white.opacity(Design.Opacity.hint))
ChipStatRow( ChipStatRow(
icon: "plusminus.circle.fill", icon: "plusminus.circle.fill",
iconColor: .blue, iconColor: .blue,
label: String(localized: "Total bet"), label: String(localized: "Total bet"),
value: "$\(stats.totalBetAmount)" value: "$\(session.totalBetAmount)"
) )
if stats.roundsPlayed > 0 { if session.roundsPlayed > 0 {
ChipStatRow( ChipStatRow(
icon: "equal.circle.fill", icon: "equal.circle.fill",
iconColor: .purple, iconColor: .purple,
label: String(localized: "Average bet"), label: String(localized: "Average bet"),
value: "$\(stats.totalBetAmount / stats.roundsPlayed)" value: "$\(session.averageBet)"
) )
} }
@ -286,101 +376,63 @@ struct StatisticsSheetView: View {
icon: "star.circle.fill", icon: "star.circle.fill",
iconColor: .orange, iconColor: .orange,
label: String(localized: "Biggest bet"), label: String(localized: "Biggest bet"),
value: "$\(stats.biggestBet)" value: "$\(session.biggestBet)"
) )
} }
} }
} }
// MARK: - Formatters // MARK: - Helpers
private func formatMoney(_ amount: Int) -> String { private func styleDisplayName(for rawValue: String) -> String {
if amount >= 0 { BlackjackStyle(rawValue: rawValue)?.displayName ?? rawValue.capitalized
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)
} }
} }
// MARK: - Statistics Page Type // MARK: - Statistics Tab
private enum StatisticsPage: Identifiable { private enum StatisticsTab: CaseIterable {
case global(StyleStatistics) case current
case style(BlackjackStyle, StyleStatistics) case global
case history
var id: String {
switch self {
case .global:
return "global"
case .style(let style, _):
return style.rawValue
}
}
var title: String { var title: String {
switch self { switch self {
case .global: case .current: return String(localized: "Current")
return String(localized: "GLOBAL") case .global: return String(localized: "Global")
case .style(let style, _): case .history: return String(localized: "History")
return style.displayName.uppercased()
} }
} }
var icon: String { var icon: String {
switch self { switch self {
case .global: case .current: return "play.circle.fill"
return "globe" case .global: return "globe"
case .style(let style, _): case .history: return "clock.arrow.circlepath"
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
} }
} }
} }
// MARK: - Supporting Views // 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 { private struct OutcomeCircle: View {
let label: String let label: String
let count: Int let count: Int
@ -405,7 +457,6 @@ private struct OutcomeCircle: View {
.stroke(color, lineWidth: Design.LineWidth.thick) .stroke(color, lineWidth: Design.LineWidth.thick)
.frame(width: Size.outcomeCircleSize, height: Size.outcomeCircleSize) .frame(width: Size.outcomeCircleSize, height: Size.outcomeCircleSize)
// Inner filled circle
Circle() Circle()
.fill(color.opacity(Design.Opacity.medium)) .fill(color.opacity(Design.Opacity.medium))
.frame(width: Size.outcomeCircleInner, height: Size.outcomeCircleInner) .frame(width: Size.outcomeCircleInner, height: Size.outcomeCircleInner)
@ -449,7 +500,6 @@ private struct ChipStatRow: View {
var body: some View { var body: some View {
HStack { HStack {
// Chip-style icon
ZStack { ZStack {
Circle() Circle()
.fill(iconColor) .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 // MARK: - Local Size Constants
private enum Size { private enum Size {

View File

@ -56,11 +56,24 @@ Professional-grade tools for learning the Hi-Lo system:
- **Betting Hints** — Recommendations based on count advantage - **Betting Hints** — Recommendations based on count advantage
- **Illustrious 18** — Count-adjusted strategy deviations - **Illustrious 18** — Count-adjusted strategy deviations
### 📊 Statistics Tracking ### 📊 Session-Based Statistics
- Win rate, blackjack count, bust rate Track your play sessions like a real casino visit:
- Session net profit/loss
- Biggest wins and losses - **Current Session** — Live stats for your active session
- Complete round history - **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 ### ☁️ iCloud Sync
- Balance and statistics sync across devices - Balance and statistics sync across devices
@ -82,7 +95,7 @@ Blackjack/
│ ├── Hand.swift # BlackjackHand model │ ├── Hand.swift # BlackjackHand model
│ └── SideBet.swift # Side bet types and evaluation │ └── SideBet.swift # Side bet types and evaluation
├── Storage/ ├── Storage/
│ └── BlackjackGameData.swift # Persistence models │ └── BlackjackGameData.swift # Persistence and session models
├── Theme/ ├── Theme/
│ └── DesignConstants.swift # Design system tokens │ └── DesignConstants.swift # Design system tokens
├── Views/ ├── Views/

View File

@ -479,6 +479,91 @@ sound.hapticError() // Error notification
sound.hapticWarning() // Warning 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<MyGameStats>
```
**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 ### 💾 Cloud Storage
**CloudSyncManager** - Saves game data locally and syncs with iCloud. **CloudSyncManager** - Saves game data locally and syncs with iCloud.
@ -681,6 +766,7 @@ CasinoKit/
├── Package.swift ├── Package.swift
├── README.md ├── README.md
├── GAME_TEMPLATE.md # Guide for creating new games ├── GAME_TEMPLATE.md # Guide for creating new games
├── SESSION_SYSTEM.md # Session tracking documentation
├── Sources/CasinoKit/ ├── Sources/CasinoKit/
│ ├── CasinoKit.swift │ ├── CasinoKit.swift
│ ├── Exports.swift │ ├── Exports.swift
@ -690,7 +776,11 @@ CasinoKit/
│ │ ├── ChipDenomination.swift │ │ ├── ChipDenomination.swift
│ │ ├── TableLimits.swift # Betting limit presets │ │ ├── TableLimits.swift # Betting limit presets
│ │ ├── OnboardingState.swift # Onboarding tracking │ │ ├── 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/ │ ├── Views/
│ │ ├── Cards/ │ │ ├── Cards/
│ │ │ ├── CardView.swift │ │ │ ├── CardView.swift
@ -724,8 +814,10 @@ CasinoKit/
│ │ │ └── ActionButton.swift # Deal/Hit/Stand buttons │ │ │ └── ActionButton.swift # Deal/Hit/Stand buttons
│ │ ├── Zones/ │ │ ├── Zones/
│ │ │ └── BettingZone.swift # Tappable betting area │ │ │ └── BettingZone.swift # Tappable betting area
│ │ └── Settings/ │ │ ├── Settings/
│ │ └── SettingsComponents.swift # Toggle, pickers │ │ │ └── SettingsComponents.swift # Toggle, pickers
│ │ └── Session/
│ │ └── SessionViews.swift # Session UI components
│ ├── Audio/ │ ├── Audio/
│ │ └── SoundManager.swift │ │ └── SoundManager.swift
│ ├── Storage/ │ ├── Storage/
@ -793,6 +885,13 @@ private var fontSize: CGFloat = CasinoDesign.BaseFontSize.body
## Version History ## Version History
- **1.1.0** - Session system
- Generic `GameSession<Stats>` 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 - **1.0.0** - Initial release
- Card and Chip components - Card and Chip components
- Sheet container views - Sheet container views

409
CasinoKit/SESSION_SYSTEM.md Normal file
View File

@ -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<Stats> - 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<Stats>
A generic session that works with any game-specific stats type.
```swift
public struct GameSession<Stats: GameSpecificStats>: 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<BlackjackStats>
```
**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<BaccaratStats>
```
### SessionManagedGame Protocol
Game state classes conform to this to get session management.
```swift
@MainActor
public protocol SessionManagedGame: AnyObject {
associatedtype Stats: GameSpecificStats
var currentSession: GameSession<Stats>? { get set }
var sessionHistory: [GameSession<Stats>] { 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<MyGameStats>
```
### 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<BlackjackStats>`
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<Stats>? { get set }
var sessionHistory: [GameSession<Stats>] { get set }
}
```

View File

@ -91,6 +91,19 @@
// - CloudSyncManager // - CloudSyncManager
// - PersistableGameData (protocol) // - PersistableGameData (protocol)
// MARK: - Sessions
// - GameSession<Stats> (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 // MARK: - Debug
// - debugBorder(_:color:label:) View modifier // - debugBorder(_:color:label:) View modifier

View File

@ -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<Stats: GameSpecificStats>: 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<Stats: GameSpecificStats>() -> AggregatedSessionStats
where Element == GameSession<Stats> {
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: GameSpecificStats>() -> Stats
where Element == GameSession<Stats> {
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
}
}

View File

@ -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<BlackjackStats>?
/// var sessionHistory: [GameSession<BlackjackStats>] = []
/// // ... 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<Stats>? { get set }
/// History of completed sessions.
var sessionHistory: [GameSession<Stats>] { 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<Stats>(
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<Stats>] {
var sessions: [GameSession<Stats>] = []
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<Stats>] {
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<Stats>? { get set }
var sessionHistory: [GameSession<Stats>] { get set }
}

View File

@ -334,6 +334,10 @@
} }
} }
}, },
"ACTIVE" : {
"comment" : "A status label indicating that a session is currently active.",
"isCommentAutoGenerated" : true
},
"Add $%lld more to meet minimum" : { "Add $%lld more to meet minimum" : {
"comment" : "Hint shown when bet is below table minimum. The argument is the dollar amount needed.", "comment" : "Hint shown when bet is below table minimum. The argument is the dollar amount needed.",
"localizations" : { "localizations" : {
@ -478,6 +482,14 @@
"comment" : "The accessibility label for the betting hint view.", "comment" : "The accessibility label for the betting hint view.",
"isCommentAutoGenerated" : true "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" : { "Card face down" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -659,6 +671,10 @@
} }
} }
}, },
"Current Session" : {
"comment" : "A label for the header of the current session section.",
"isCommentAutoGenerated" : true
},
"Data Storage" : { "Data Storage" : {
"comment" : "Title of a section in the Privacy Policy View that discusses how game data is stored.", "comment" : "Title of a section in the Privacy Policy View that discusses how game data is stored.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -823,6 +839,10 @@
} }
} }
}, },
"Duration" : {
"comment" : "A label displayed next to the duration of a session",
"isCommentAutoGenerated" : true
},
"Eight" : { "Eight" : {
"localizations" : { "localizations" : {
"en" : { "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" : { "Exclusive VIP room" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -1029,6 +1061,10 @@
}, },
"Got it" : { "Got it" : {
},
"Hands" : {
"comment" : "Label for the number of hands played in the current session.",
"isCommentAutoGenerated" : true
}, },
"Hearts" : { "Hearts" : {
"localizations" : { "localizations" : {
@ -1425,6 +1461,13 @@
} }
} }
} }
},
"Net" : {
"comment" : "Label for the net result in the current session header.",
"isCommentAutoGenerated" : true
},
"Net Result" : {
}, },
"New Round" : { "New Round" : {
"comment" : "A button label that initiates a new round of a casino game.", "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" "version" : "1.1"

View File

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