Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
982d54ed1d
commit
247435a405
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
107
Blackjack/Blackjack/Models/BlackjackStats.swift
Normal file
107
Blackjack/Blackjack/Models/BlackjackStats.swift
Normal 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>
|
||||||
|
|
||||||
@ -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" : {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
// Content based on selected tab
|
||||||
pageIndicator
|
switch selectedTab {
|
||||||
}
|
case .current:
|
||||||
|
currentSessionContent
|
||||||
// Current page content
|
case .global:
|
||||||
if selectedPage < pages.count {
|
globalStatsContent
|
||||||
statisticsContent(for: pages[selectedPage])
|
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))
|
||||||
|
|
||||||
private var pageIndicator: some View {
|
Text(String(localized: "No Active Session"))
|
||||||
HStack(spacing: Design.Spacing.small) {
|
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
|
||||||
ForEach(pages.indices, id: \.self) { index in
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
Circle()
|
|
||||||
.fill(index == selectedPage ? Color.Sheet.accent : Color.white.opacity(Design.Opacity.light))
|
Text(String(localized: "Start playing to begin tracking your session."))
|
||||||
.frame(width: Design.Spacing.small, height: Design.Spacing.small)
|
.font(.system(size: Design.BaseFontSize.body))
|
||||||
.onTapGesture {
|
.foregroundStyle(.white.opacity(Design.Opacity.light))
|
||||||
withAnimation(.spring(duration: Design.Animation.springDuration)) {
|
.multilineTextAlignment(.center)
|
||||||
selectedPage = index
|
}
|
||||||
}
|
.padding(Design.Spacing.xxLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Global Stats Content
|
||||||
|
|
||||||
|
private var globalStatsContent: some View {
|
||||||
|
let stats = state.aggregatedStats
|
||||||
|
let gameStats = state.aggregatedBlackjackStats
|
||||||
|
|
||||||
|
return Group {
|
||||||
|
// Summary section
|
||||||
|
SheetSection(title: String(localized: "ALL TIME SUMMARY"), icon: "globe") {
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
// Sessions overview
|
||||||
|
HStack(spacing: Design.Spacing.large) {
|
||||||
|
StatColumn(
|
||||||
|
value: "\(stats.totalSessions)",
|
||||||
|
label: String(localized: "Sessions")
|
||||||
|
)
|
||||||
|
StatColumn(
|
||||||
|
value: "\(stats.totalRoundsPlayed)",
|
||||||
|
label: String(localized: "Hands")
|
||||||
|
)
|
||||||
|
StatColumn(
|
||||||
|
value: SessionFormatter.formatPercent(stats.winRate),
|
||||||
|
label: String(localized: "Win Rate"),
|
||||||
|
valueColor: stats.winRate >= 50 ? .green : .orange
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Divider().background(Color.white.opacity(Design.Opacity.hint))
|
||||||
|
|
||||||
|
// Financial summary
|
||||||
|
HStack(spacing: Design.Spacing.large) {
|
||||||
|
StatColumn(
|
||||||
|
value: SessionFormatter.formatMoney(stats.totalWinnings),
|
||||||
|
label: String(localized: "Net"),
|
||||||
|
valueColor: stats.totalWinnings >= 0 ? .green : .red
|
||||||
|
)
|
||||||
|
StatColumn(
|
||||||
|
value: SessionFormatter.formatDuration(stats.totalPlayTime),
|
||||||
|
label: String(localized: "Time")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blackjack-specific stats
|
||||||
|
SheetSection(title: String(localized: "GAME STATS"), icon: "suit.spade.fill") {
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
ForEach(gameStats.displayItems) { item in
|
||||||
|
GameStatRow(item: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session performance
|
||||||
|
SheetSection(title: String(localized: "SESSION PERFORMANCE"), icon: "chart.bar.fill") {
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
HStack {
|
||||||
|
Text(String(localized: "Winning sessions"))
|
||||||
|
Spacer()
|
||||||
|
Text("\(stats.winningSessions)")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
.bold()
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text(String(localized: "Losing sessions"))
|
||||||
|
Spacer()
|
||||||
|
Text("\(stats.losingSessions)")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.bold()
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider().background(Color.white.opacity(Design.Opacity.hint))
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text(String(localized: "Best session"))
|
||||||
|
Spacer()
|
||||||
|
Text(SessionFormatter.formatMoney(stats.bestSession))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
.bold()
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text(String(localized: "Worst session"))
|
||||||
|
Spacer()
|
||||||
|
Text(SessionFormatter.formatMoney(stats.worstSession))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.bold()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.system(size: Design.BaseFontSize.body))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Statistics Content
|
// MARK: - Session History Content
|
||||||
|
|
||||||
|
private var sessionHistoryContent: some View {
|
||||||
|
Group {
|
||||||
|
if state.sessionHistory.isEmpty && state.currentSession == nil {
|
||||||
|
emptyHistoryView
|
||||||
|
} else {
|
||||||
|
LazyVStack(spacing: Design.Spacing.medium) {
|
||||||
|
// Current session at top if exists (taps go to Current tab)
|
||||||
|
if let current = state.currentSession {
|
||||||
|
Button {
|
||||||
|
withAnimation {
|
||||||
|
selectedTab = .current
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
SessionSummaryRow(
|
||||||
|
styleDisplayName: styleDisplayName(for: current.gameStyle),
|
||||||
|
duration: current.duration,
|
||||||
|
roundsPlayed: current.roundsPlayed,
|
||||||
|
netResult: current.netResult,
|
||||||
|
startTime: current.startTime,
|
||||||
|
isActive: true,
|
||||||
|
endReason: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Historical sessions - tap to view details
|
||||||
|
ForEach(state.sessionHistory) { session in
|
||||||
|
Button {
|
||||||
|
selectedSession = session
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
SessionSummaryRow(
|
||||||
|
styleDisplayName: styleDisplayName(for: session.gameStyle),
|
||||||
|
duration: session.duration,
|
||||||
|
roundsPlayed: session.roundsPlayed,
|
||||||
|
netResult: session.netResult,
|
||||||
|
startTime: session.startTime,
|
||||||
|
isActive: false,
|
||||||
|
endReason: session.endReason
|
||||||
|
)
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: Design.BaseFontSize.small, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.light))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyHistoryView: some View {
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
Image(systemName: "clock.arrow.circlepath")
|
||||||
|
.font(.system(size: Design.BaseFontSize.xxLarge * 2))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.light))
|
||||||
|
|
||||||
|
Text(String(localized: "No Session History"))
|
||||||
|
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
|
||||||
|
Text(String(localized: "Completed sessions will appear here."))
|
||||||
|
.font(.system(size: Design.BaseFontSize.body))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.light))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.xxLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Stats Section
|
||||||
|
|
||||||
@ViewBuilder
|
@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 {
|
||||||
|
|||||||
@ -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/
|
||||||
|
|||||||
@ -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
409
CasinoKit/SESSION_SYSTEM.md
Normal 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 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
238
CasinoKit/Sources/CasinoKit/Models/Session/SessionManager.swift
Normal file
238
CasinoKit/Sources/CasinoKit/Models/Session/SessionManager.swift
Normal 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 }
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
|||||||
419
CasinoKit/Sources/CasinoKit/Views/Session/SessionViews.swift
Normal file
419
CasinoKit/Sources/CasinoKit/Views/Session/SessionViews.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user