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.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class GameState {
|
||||
// MARK: - Core State
|
||||
final class GameState: SessionManagedGame {
|
||||
// MARK: - SessionManagedGame
|
||||
|
||||
typealias Stats = BlackjackStats
|
||||
|
||||
/// Current player balance.
|
||||
private(set) var balance: Int
|
||||
var balance: Int
|
||||
|
||||
/// Current game phase.
|
||||
private(set) var currentPhase: GamePhase = .betting
|
||||
@ -108,29 +110,29 @@ final class GameState {
|
||||
/// The result of the last round.
|
||||
private(set) var lastRoundResult: RoundResult?
|
||||
|
||||
/// Round history for statistics.
|
||||
/// Round history for current session statistics.
|
||||
private(set) var roundHistory: [RoundResult] = []
|
||||
|
||||
// MARK: - Statistics (persisted)
|
||||
// MARK: - Session Tracking (SessionManagedGame)
|
||||
|
||||
private(set) var totalWinnings: Int = 0
|
||||
private(set) var biggestWin: Int = 0
|
||||
private(set) var biggestLoss: Int = 0
|
||||
private(set) var blackjackCount: Int = 0
|
||||
private(set) var bustCount: Int = 0
|
||||
private(set) var totalPlayTime: TimeInterval = 0
|
||||
/// The currently active session.
|
||||
var currentSession: BlackjackSession?
|
||||
|
||||
/// Per-style statistics (keyed by style rawValue).
|
||||
private(set) var styleStats: [String: StyleStatistics] = [:]
|
||||
/// History of completed sessions.
|
||||
var sessionHistory: [BlackjackSession] = []
|
||||
|
||||
// MARK: - Round Timing
|
||||
/// Starting balance for new sessions (from settings).
|
||||
var startingBalance: Int { settings.startingBalance }
|
||||
|
||||
/// When the current round started (for duration tracking).
|
||||
private var roundStartTime: Date?
|
||||
/// Current game style identifier.
|
||||
var currentGameStyle: String { settings.gameStyle.rawValue }
|
||||
|
||||
/// The bet amount for the current round (tracked for stats).
|
||||
private var roundBetAmount: Int = 0
|
||||
|
||||
/// Whether a session end has been requested (shows confirmation).
|
||||
var showEndSessionConfirmation: Bool = false
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
/// iCloud sync manager for game data.
|
||||
@ -349,8 +351,8 @@ final class GameState {
|
||||
syncSoundSettings()
|
||||
loadSavedGame()
|
||||
|
||||
// Start timing for the first round (includes betting phase)
|
||||
roundStartTime = Date()
|
||||
// Ensure we have an active session
|
||||
ensureActiveSession()
|
||||
}
|
||||
|
||||
/// Syncs sound settings with SoundManager.
|
||||
@ -371,79 +373,56 @@ final class GameState {
|
||||
private func loadSavedGame() {
|
||||
let data = persistence.load()
|
||||
self.balance = data.balance
|
||||
self.totalWinnings = data.totalWinnings
|
||||
self.biggestWin = data.biggestWin
|
||||
self.biggestLoss = data.biggestLoss
|
||||
self.blackjackCount = data.blackjackCount
|
||||
self.bustCount = data.bustCount
|
||||
self.totalPlayTime = data.totalPlayTime
|
||||
self.styleStats = data.styleStats
|
||||
self.currentSession = data.currentSession
|
||||
self.sessionHistory = data.sessionHistory
|
||||
|
||||
Design.debugLog("📂 Loaded game data:")
|
||||
Design.debugLog(" - totalPlayTime: \(data.totalPlayTime) seconds")
|
||||
Design.debugLog(" - styleStats keys: \(data.styleStats.keys.joined(separator: ", "))")
|
||||
for (key, stats) in data.styleStats {
|
||||
Design.debugLog(" - \(key): rounds=\(stats.roundsPlayed), time=\(stats.totalPlayTime)s, totalBet=\(stats.totalBetAmount)")
|
||||
Design.debugLog(" - balance: \(data.balance)")
|
||||
Design.debugLog(" - currentSession: \(data.currentSession?.id.uuidString ?? "none")")
|
||||
Design.debugLog(" - sessionHistory count: \(data.sessionHistory.count)")
|
||||
if let session = data.currentSession {
|
||||
Design.debugLog(" - current session rounds: \(session.roundsPlayed), duration: \(session.duration)s")
|
||||
}
|
||||
|
||||
// Set up callback for when iCloud data arrives later
|
||||
persistence.onCloudDataReceived = { [weak self] newData in
|
||||
guard let self else { return }
|
||||
self.balance = newData.balance
|
||||
self.totalWinnings = newData.totalWinnings
|
||||
self.biggestWin = newData.biggestWin
|
||||
self.biggestLoss = newData.biggestLoss
|
||||
self.blackjackCount = newData.blackjackCount
|
||||
self.bustCount = newData.bustCount
|
||||
self.totalPlayTime = newData.totalPlayTime
|
||||
self.styleStats = newData.styleStats
|
||||
self.currentSession = newData.currentSession
|
||||
self.sessionHistory = newData.sessionHistory
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves current game data to iCloud and local storage.
|
||||
private func saveGameData() {
|
||||
// Note: savedRounds are reconstructed from roundHistory with current style
|
||||
// The actual round data with style is stored during completeRound()
|
||||
let savedRounds: [SavedRoundResult] = roundHistory.map { result in
|
||||
SavedRoundResult(
|
||||
date: Date(),
|
||||
gameStyle: settings.gameStyle.rawValue,
|
||||
mainResult: result.mainHandResult.saveName,
|
||||
hadSplit: result.hadSplit,
|
||||
totalWinnings: result.totalWinnings,
|
||||
roundDuration: 0 // Duration tracked separately in styleStats
|
||||
)
|
||||
func saveGameData() {
|
||||
// Update current session before saving
|
||||
if var session = currentSession {
|
||||
session.endingBalance = balance
|
||||
// Keep session's game style in sync with current settings
|
||||
session.gameStyle = settings.gameStyle.rawValue
|
||||
currentSession = session
|
||||
}
|
||||
|
||||
let data = BlackjackGameData(
|
||||
lastModified: Date(),
|
||||
balance: balance,
|
||||
roundHistory: savedRounds,
|
||||
styleStats: styleStats,
|
||||
totalWinnings: totalWinnings,
|
||||
biggestWin: biggestWin,
|
||||
biggestLoss: biggestLoss,
|
||||
blackjackCount: blackjackCount,
|
||||
bustCount: bustCount,
|
||||
totalPlayTime: totalPlayTime
|
||||
currentSession: currentSession,
|
||||
sessionHistory: sessionHistory
|
||||
)
|
||||
persistence.save(data)
|
||||
|
||||
Design.debugLog("💾 Saved game data - session rounds: \(currentSession?.roundsPlayed ?? 0)")
|
||||
}
|
||||
|
||||
/// Clears all saved data.
|
||||
/// Clears all saved data and starts fresh.
|
||||
func clearAllData() {
|
||||
persistence.reset()
|
||||
balance = settings.startingBalance
|
||||
totalWinnings = 0
|
||||
biggestWin = 0
|
||||
biggestLoss = 0
|
||||
blackjackCount = 0
|
||||
bustCount = 0
|
||||
totalPlayTime = 0
|
||||
styleStats = [:]
|
||||
currentSession = nil
|
||||
sessionHistory = []
|
||||
roundHistory = []
|
||||
roundStartTime = nil
|
||||
roundBetAmount = 0
|
||||
startNewSession()
|
||||
newRound()
|
||||
}
|
||||
|
||||
@ -503,9 +482,9 @@ final class GameState {
|
||||
func deal() async {
|
||||
guard canDeal else { return }
|
||||
|
||||
// Track bet amount for statistics (roundStartTime was set when betting phase started)
|
||||
// Track bet amount for statistics
|
||||
roundBetAmount = currentBet + perfectPairsBet + twentyOnePlusThreeBet
|
||||
Design.debugLog("🎴 Deal started - roundBetAmount: \(roundBetAmount), time since round start: \(Date().timeIntervalSince(roundStartTime ?? Date()))s")
|
||||
Design.debugLog("🎴 Deal started - roundBetAmount: \(roundBetAmount)")
|
||||
|
||||
// Ensure enough cards for a full hand - reshuffle if needed
|
||||
if !engine.canDealNewHand {
|
||||
@ -1062,73 +1041,52 @@ final class GameState {
|
||||
roundWinnings -= sideBetsTotal
|
||||
}
|
||||
|
||||
// Calculate round duration
|
||||
let roundDuration: TimeInterval
|
||||
if let startTime = roundStartTime {
|
||||
roundDuration = Date().timeIntervalSince(startTime)
|
||||
Design.debugLog("⏱️ Round duration: \(roundDuration) seconds (start: \(startTime))")
|
||||
} else {
|
||||
roundDuration = 0
|
||||
Design.debugLog("⚠️ roundStartTime was nil - duration is 0")
|
||||
}
|
||||
totalPlayTime += roundDuration
|
||||
roundStartTime = nil
|
||||
Design.debugLog("⏱️ Total play time now: \(totalPlayTime) seconds")
|
||||
|
||||
// Update global statistics
|
||||
totalWinnings += roundWinnings
|
||||
if roundWinnings > biggestWin {
|
||||
biggestWin = roundWinnings
|
||||
}
|
||||
if roundWinnings < biggestLoss {
|
||||
biggestLoss = roundWinnings
|
||||
}
|
||||
if wasBlackjack {
|
||||
blackjackCount += 1
|
||||
}
|
||||
if hadBust {
|
||||
bustCount += 1
|
||||
}
|
||||
|
||||
// Determine if this round was a win, loss, push, or surrender for stats
|
||||
// Determine round outcome for session stats
|
||||
let mainResult = playerHands.first?.result
|
||||
let isWin = mainResult?.isWin ?? false
|
||||
let isLoss = mainResult == .lose || mainResult == .bust
|
||||
let isPush = mainResult == .push
|
||||
let isSurrender = mainResult == .surrender
|
||||
let hadDoubled = playerHands.contains { $0.isDoubledDown }
|
||||
let hadSplitHands = playerHands.count > 1
|
||||
|
||||
// Update per-style statistics
|
||||
let styleKey = settings.gameStyle.rawValue
|
||||
var stats = styleStats[styleKey] ?? StyleStatistics()
|
||||
stats.roundsPlayed += 1
|
||||
stats.totalPlayTime += roundDuration
|
||||
stats.totalWinnings += roundWinnings
|
||||
stats.totalBetAmount += roundBetAmount
|
||||
|
||||
Design.debugLog("📊 Style[\(styleKey)] stats update:")
|
||||
Design.debugLog(" - roundBetAmount: \(roundBetAmount)")
|
||||
Design.debugLog(" - stats.totalBetAmount: \(stats.totalBetAmount)")
|
||||
Design.debugLog(" - stats.totalPlayTime: \(stats.totalPlayTime) seconds")
|
||||
Design.debugLog(" - stats.roundsPlayed: \(stats.roundsPlayed)")
|
||||
|
||||
if roundWinnings > stats.biggestWin {
|
||||
stats.biggestWin = roundWinnings
|
||||
}
|
||||
if roundWinnings < stats.biggestLoss {
|
||||
stats.biggestLoss = roundWinnings
|
||||
}
|
||||
if roundBetAmount > stats.biggestBet {
|
||||
stats.biggestBet = roundBetAmount
|
||||
// Determine the round outcome enum
|
||||
let outcome: RoundOutcome
|
||||
if isWin {
|
||||
outcome = .win
|
||||
} else if isPush || isSurrender {
|
||||
outcome = .push // Surrender and push treated as push for session stats
|
||||
} else {
|
||||
outcome = .lose
|
||||
}
|
||||
|
||||
if isWin { stats.wins += 1 }
|
||||
if isLoss { stats.losses += 1 }
|
||||
if isPush { stats.pushes += 1 }
|
||||
if isSurrender { stats.surrenders += 1 }
|
||||
if wasBlackjack { stats.blackjacks += 1 }
|
||||
if hadBust { stats.busts += 1 }
|
||||
// Capture values for closure
|
||||
let tookInsurance = insuranceBet > 0
|
||||
let wonInsurance = insResult == .insuranceWin
|
||||
|
||||
styleStats[styleKey] = stats
|
||||
// Record round in session using CasinoKit protocol
|
||||
recordSessionRound(
|
||||
winnings: roundWinnings,
|
||||
betAmount: roundBetAmount,
|
||||
outcome: outcome
|
||||
) { stats in
|
||||
// Update Blackjack-specific stats
|
||||
if wasBlackjack { stats.blackjacks += 1 }
|
||||
if hadBust { stats.busts += 1 }
|
||||
if isSurrender { stats.surrenders += 1 }
|
||||
if hadDoubled { stats.doubles += 1 }
|
||||
if hadSplitHands { stats.splits += 1 }
|
||||
if tookInsurance {
|
||||
stats.insuranceTaken += 1
|
||||
if wonInsurance {
|
||||
stats.insuranceWon += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Design.debugLog("📊 Session stats update:")
|
||||
Design.debugLog(" - roundsPlayed: \(currentSession?.roundsPlayed ?? 0)")
|
||||
Design.debugLog(" - duration: \(currentSession?.duration ?? 0) seconds")
|
||||
|
||||
// Create round result with all hand results, per-hand winnings, and side bets
|
||||
let allHandResults = playerHands.map { $0.result ?? .lose }
|
||||
@ -1229,13 +1187,23 @@ final class GameState {
|
||||
lastRoundResult = nil
|
||||
currentPhase = .betting
|
||||
|
||||
// Start timing for the new round (includes betting phase)
|
||||
roundStartTime = Date()
|
||||
Design.debugLog("🎰 New round started - roundStartTime: \(roundStartTime!)")
|
||||
|
||||
sound.play(.newRound)
|
||||
}
|
||||
|
||||
// MARK: - SessionManagedGame Implementation
|
||||
|
||||
/// Resets game-specific state when starting a new session.
|
||||
func resetForNewSession() {
|
||||
roundHistory = []
|
||||
engine.reshuffle()
|
||||
newRound()
|
||||
}
|
||||
|
||||
/// Aggregated Blackjack-specific stats from all sessions.
|
||||
var aggregatedBlackjackStats: BlackjackStats {
|
||||
allSessions.aggregatedBlackjackStats()
|
||||
}
|
||||
|
||||
// MARK: - Game Reset
|
||||
|
||||
/// Resets the entire game (keeps statistics).
|
||||
@ -1243,8 +1211,10 @@ final class GameState {
|
||||
balance = settings.startingBalance
|
||||
roundHistory = []
|
||||
engine.reshuffle()
|
||||
startNewSession()
|
||||
newRound()
|
||||
saveGameData()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
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." : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -874,6 +878,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ALL TIME SUMMARY" : {
|
||||
"comment" : "Title for a section in the statistics sheet that provides a summary of the user's overall performance over all time.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Allow doubling on split hands" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -1131,6 +1139,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BALANCE" : {
|
||||
"comment" : "Title of a section in the session detail view that shows the user's starting and ending balances.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Basic Strategy" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -1229,6 +1241,10 @@
|
||||
"comment" : "Label in the statistics sheet for the player's best single win.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Best session" : {
|
||||
"comment" : "A label describing the best session a user has played.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Bet 2x minimum" : {
|
||||
"comment" : "Betting recommendation based on a true count of 1.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@ -2068,6 +2084,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Completed sessions will appear here." : {
|
||||
"comment" : "A description below the label \"Your Session History\" in the StatisticsSheetView, explaining that completed sessions will be listed there.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Cost: $%lld (half your bet)" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -2159,6 +2179,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Current" : {
|
||||
|
||||
},
|
||||
"Current bet $%lld" : {
|
||||
"comment" : "A hint that appears when a user taps on a side bet zone. The text varies depending on whether a bet is currently placed or not.",
|
||||
@ -3043,6 +3066,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Doubles" : {
|
||||
"comment" : "Label for a stat item in the statistics UI that shows the number of times a hand was doubled down.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Enable 'Card Count' in Settings to practice." : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -3109,6 +3136,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"End Session" : {
|
||||
"comment" : "The text for a button that ends the current game session.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"End Session?" : {
|
||||
"comment" : "A confirmation dialog title that asks if the user wants to end their current session.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Ended manually" : {
|
||||
"comment" : "A description of a session that ended manually (e.g. by the user closing the game).",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Ending balance" : {
|
||||
"comment" : "A label displayed below the user's ending balance in the session detail view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"European" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -3309,6 +3352,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GAME STATS" : {
|
||||
"comment" : "Title for a section in the statistics sheet dedicated to blackjack-specific statistics.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"GAME STYLE" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -3430,7 +3477,7 @@
|
||||
"Get closer to 21 than the dealer without going over" : {
|
||||
|
||||
},
|
||||
"GLOBAL" : {
|
||||
"Global" : {
|
||||
"comment" : "Title for the \"Global\" tab in the statistics sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
@ -3478,6 +3525,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Hands" : {
|
||||
"comment" : "Label for the number of blackjack hands played in a session.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Hands played" : {
|
||||
"comment" : "A label describing the number of hands a player has played in a game.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -3572,6 +3623,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"History" : {
|
||||
"comment" : "Title of the statistics tab that shows the user's session history.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Hit" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -4194,6 +4249,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Losing sessions" : {
|
||||
"comment" : "A label describing the number of sessions that the user lost.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Losses" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@ -4217,8 +4276,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Lost hands" : {
|
||||
"comment" : "Label for a circle that shows the number of lost blackjack hands in the statistics sheet.",
|
||||
"Lost" : {
|
||||
"comment" : "Label for a game outcome circle indicating a loss.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Lower house edge" : {
|
||||
@ -4474,7 +4533,6 @@
|
||||
}
|
||||
},
|
||||
"Net" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -4496,6 +4554,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Net result" : {
|
||||
"comment" : "Label for a row in the \"Chips stats\" section of the session detail view, showing the net result of the session (i.e. the difference between the starting and ending balance).",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Never" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -4563,6 +4625,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"No Active Session" : {
|
||||
"comment" : "A message displayed when there is no active blackjack session to display statistics for.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No Hand" : {
|
||||
"comment" : "Description of a 21+3 side bet outcome when there is no qualifying hand.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@ -4654,6 +4720,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"No Session History" : {
|
||||
"comment" : "A message displayed when a user has no session history.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No surrender option." : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -5189,6 +5259,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Push" : {
|
||||
"comment" : "Label for the \"Push\" outcome in the game stats section of the statistics sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"PUSH" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -5235,10 +5309,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Pushed hands" : {
|
||||
"comment" : "Label for the \"Pushed hands\" outcome circle in the statistics sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Pushes" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@ -5262,6 +5332,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Ran out of chips" : {
|
||||
"comment" : "A description of why a blackjack session ended when the player ran out of chips.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Re-split Aces" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -5604,6 +5678,10 @@
|
||||
},
|
||||
"Select a chip and tap the bet area" : {
|
||||
|
||||
},
|
||||
"SESSION PERFORMANCE" : {
|
||||
"comment" : "Title of a section in the statistics sheet that shows performance metrics for individual sessions.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"SESSION SUMMARY" : {
|
||||
"extractionState" : "stale",
|
||||
@ -5628,6 +5706,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Sessions" : {
|
||||
"comment" : "Label for the number of blackjack game sessions.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Settings" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -6110,6 +6192,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Splits" : {
|
||||
"comment" : "Label for the number of split hands in the statistics UI.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Stand" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -6247,8 +6333,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Start playing to begin tracking your session." : {
|
||||
"comment" : "A description text displayed in the \"No Active Session\" view, explaining that the user needs to start playing to see their session statistics.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Start with $1,000 and play risk-free" : {
|
||||
|
||||
},
|
||||
"Starting balance" : {
|
||||
"comment" : "A label for the starting balance in the Balance section of a session detail view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"STARTING BALANCE" : {
|
||||
"localizations" : {
|
||||
@ -6773,6 +6867,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Time" : {
|
||||
"comment" : "Label for the duration of a blackjack game.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Total bet" : {
|
||||
"comment" : "Label for the total bet value in the Statistics Sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -6987,8 +7085,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"win rate" : {
|
||||
"comment" : "A description of what \"win rate\" means in the context of a casino game.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Win Rate" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -7056,6 +7157,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Winning sessions" : {
|
||||
"comment" : "A label describing the number of sessions the user has won.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Wins" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@ -7079,8 +7184,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Won hands" : {
|
||||
"comment" : "Label for a circle that represents the number of hands the user has won in a statistics sheet.",
|
||||
"Won" : {
|
||||
"comment" : "Label for a game outcome circle that indicates a win.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Worst" : {
|
||||
@ -7110,6 +7215,10 @@
|
||||
"comment" : "Description of a chip stat row when displaying the worst loss.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Worst session" : {
|
||||
"comment" : "A label for the worst session's winnings in the statistics sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Yes ($%lld)" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -7132,6 +7241,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"You played %lld hands with a net result of %@. This session will be saved to your history." : {
|
||||
"comment" : "A message that appears when a user ends a game session. It includes the number of hands played and the net result of the session.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "You played %1$lld hands with a net result of %2$@. This session will be saved to your history."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"You've run out of chips!" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
|
||||
@ -8,68 +8,36 @@
|
||||
import Foundation
|
||||
import CasinoKit
|
||||
|
||||
/// Saved round result for history.
|
||||
struct SavedRoundResult: Codable, Equatable {
|
||||
let date: Date
|
||||
let gameStyle: String // "vegas", "atlantic", "european", "custom"
|
||||
let mainResult: String // "blackjack", "win", "lose", "push", "bust", "surrender"
|
||||
let hadSplit: Bool
|
||||
let totalWinnings: Int
|
||||
let roundDuration: TimeInterval // Duration in seconds
|
||||
}
|
||||
|
||||
/// Per-style statistics for tracking.
|
||||
struct StyleStatistics: Codable, Equatable {
|
||||
var roundsPlayed: Int = 0
|
||||
var wins: Int = 0
|
||||
var losses: Int = 0
|
||||
var pushes: Int = 0
|
||||
var blackjacks: Int = 0
|
||||
var busts: Int = 0
|
||||
var surrenders: Int = 0
|
||||
var totalWinnings: Int = 0
|
||||
var biggestWin: Int = 0
|
||||
var biggestLoss: Int = 0
|
||||
var totalPlayTime: TimeInterval = 0 // Cumulative seconds
|
||||
var totalBetAmount: Int = 0
|
||||
var biggestBet: Int = 0
|
||||
}
|
||||
|
||||
/// Persistent game data that syncs to iCloud.
|
||||
struct BlackjackGameData: PersistableGameData {
|
||||
struct BlackjackGameData: PersistableGameData, SessionPersistable {
|
||||
static let gameIdentifier = "blackjack"
|
||||
|
||||
var roundsPlayed: Int { roundHistory.count }
|
||||
var roundsPlayed: Int {
|
||||
// Total rounds from all sessions
|
||||
let historicalRounds = sessionHistory.reduce(0) { $0 + $1.roundsPlayed }
|
||||
let currentRounds = currentSession?.roundsPlayed ?? 0
|
||||
return historicalRounds + currentRounds
|
||||
}
|
||||
|
||||
var lastModified: Date
|
||||
|
||||
static var empty: BlackjackGameData {
|
||||
BlackjackGameData(
|
||||
lastModified: Date(),
|
||||
balance: 10_000,
|
||||
roundHistory: [],
|
||||
styleStats: [:],
|
||||
totalWinnings: 0,
|
||||
biggestWin: 0,
|
||||
biggestLoss: 0,
|
||||
blackjackCount: 0,
|
||||
bustCount: 0,
|
||||
totalPlayTime: 0
|
||||
currentSession: nil,
|
||||
sessionHistory: []
|
||||
)
|
||||
}
|
||||
|
||||
/// Current player balance.
|
||||
var balance: Int
|
||||
var roundHistory: [SavedRoundResult]
|
||||
|
||||
/// Per-style statistics keyed by style rawValue.
|
||||
var styleStats: [String: StyleStatistics]
|
||||
/// The currently active session (nil if no session started).
|
||||
var currentSession: BlackjackSession?
|
||||
|
||||
// Legacy global stats (kept for backward compatibility)
|
||||
var totalWinnings: Int
|
||||
var biggestWin: Int
|
||||
var biggestLoss: Int
|
||||
var blackjackCount: Int
|
||||
var bustCount: Int
|
||||
var totalPlayTime: TimeInterval
|
||||
/// History of completed sessions.
|
||||
var sessionHistory: [BlackjackSession]
|
||||
}
|
||||
|
||||
/// Persistent settings data that syncs to iCloud.
|
||||
@ -130,4 +98,3 @@ struct BlackjackSettingsData: PersistableGameData {
|
||||
var hapticsEnabled: Bool
|
||||
var soundVolume: Float
|
||||
}
|
||||
|
||||
|
||||
@ -70,8 +70,10 @@ struct GameTableView: View {
|
||||
SettingsView(settings: settings, gameState: gameState)
|
||||
}
|
||||
.onChange(of: showSettings) { wasShowing, isShowing in
|
||||
// When settings sheet dismisses, check if we should show welcome
|
||||
// When settings sheet dismisses, sync session and check welcome
|
||||
if wasShowing && !isShowing {
|
||||
// Sync current session with any settings changes (e.g., game style)
|
||||
state.saveGameData()
|
||||
checkForWelcomeSheet()
|
||||
}
|
||||
}
|
||||
|
||||
@ -283,7 +283,7 @@ struct SettingsView: View {
|
||||
.font(.system(size: Design.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
|
||||
Text("\(state.roundsPlayed)")
|
||||
Text("\(state.aggregatedStats.totalRoundsPlayed)")
|
||||
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
@ -295,11 +295,11 @@ struct SettingsView: View {
|
||||
.font(.system(size: Design.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
|
||||
let winnings = state.totalWinnings
|
||||
let winnings = state.aggregatedStats.totalWinnings
|
||||
Text(winnings >= 0 ? "+$\(winnings)" : "-$\(abs(winnings))")
|
||||
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(winnings >= 0 ? .green : .red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
@ -2,198 +2,304 @@
|
||||
// StatisticsSheetView.swift
|
||||
// Blackjack
|
||||
//
|
||||
// Game statistics with Global and per-style tabs.
|
||||
// Game statistics with session history and per-style stats.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CasinoKit
|
||||
|
||||
struct StatisticsSheetView: View {
|
||||
let state: GameState
|
||||
@Bindable var state: GameState
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var selectedPage: Int = 0
|
||||
|
||||
/// All available statistics pages (Global + each style).
|
||||
private var pages: [StatisticsPage] {
|
||||
var result: [StatisticsPage] = [
|
||||
.global(computeGlobalStats())
|
||||
]
|
||||
|
||||
// Add pages for each style that has been played
|
||||
for style in BlackjackStyle.allCases where style != .custom {
|
||||
if let stats = state.styleStats[style.rawValue], stats.roundsPlayed > 0 {
|
||||
result.append(.style(style, stats))
|
||||
}
|
||||
}
|
||||
|
||||
// Add custom if it has been played
|
||||
if let customStats = state.styleStats[BlackjackStyle.custom.rawValue], customStats.roundsPlayed > 0 {
|
||||
result.append(.style(.custom, customStats))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Computes aggregated global statistics from all styles.
|
||||
private func computeGlobalStats() -> StyleStatistics {
|
||||
var global = StyleStatistics()
|
||||
|
||||
for (_, stats) in state.styleStats {
|
||||
global.roundsPlayed += stats.roundsPlayed
|
||||
global.wins += stats.wins
|
||||
global.losses += stats.losses
|
||||
global.pushes += stats.pushes
|
||||
global.blackjacks += stats.blackjacks
|
||||
global.busts += stats.busts
|
||||
global.surrenders += stats.surrenders
|
||||
global.totalWinnings += stats.totalWinnings
|
||||
global.totalPlayTime += stats.totalPlayTime
|
||||
global.totalBetAmount += stats.totalBetAmount
|
||||
|
||||
if stats.biggestWin > global.biggestWin {
|
||||
global.biggestWin = stats.biggestWin
|
||||
}
|
||||
if stats.biggestLoss < global.biggestLoss {
|
||||
global.biggestLoss = stats.biggestLoss
|
||||
}
|
||||
if stats.biggestBet > global.biggestBet {
|
||||
global.biggestBet = stats.biggestBet
|
||||
}
|
||||
}
|
||||
|
||||
// If no style stats exist yet, use session data
|
||||
if global.roundsPlayed == 0 {
|
||||
global.roundsPlayed = state.roundHistory.count
|
||||
global.wins = state.roundHistory.filter { $0.mainHandResult.isWin }.count
|
||||
global.losses = state.roundHistory.filter { $0.mainHandResult == .lose || $0.mainHandResult == .bust }.count
|
||||
global.pushes = state.roundHistory.filter { $0.mainHandResult == .push }.count
|
||||
global.blackjacks = state.roundHistory.filter { $0.mainHandResult == .blackjack }.count
|
||||
global.busts = state.roundHistory.filter { $0.mainHandResult == .bust }.count
|
||||
global.surrenders = state.roundHistory.filter { $0.mainHandResult == .surrender }.count
|
||||
global.totalWinnings = state.roundHistory.reduce(0) { $0 + $1.totalWinnings }
|
||||
global.totalPlayTime = state.totalPlayTime
|
||||
}
|
||||
|
||||
return global
|
||||
}
|
||||
@State private var selectedTab: StatisticsTab = .current
|
||||
@State private var selectedSession: BlackjackSession?
|
||||
|
||||
var body: some View {
|
||||
SheetContainerView(
|
||||
title: String(localized: "Statistics"),
|
||||
content: {
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
// Page selector with current style header
|
||||
pageHeader
|
||||
|
||||
// Page indicator dots
|
||||
pageIndicator
|
||||
}
|
||||
// Tab selector
|
||||
tabSelector
|
||||
|
||||
// Current page content
|
||||
if selectedPage < pages.count {
|
||||
statisticsContent(for: pages[selectedPage])
|
||||
// Content based on selected tab
|
||||
switch selectedTab {
|
||||
case .current:
|
||||
currentSessionContent
|
||||
case .global:
|
||||
globalStatsContent
|
||||
case .history:
|
||||
sessionHistoryContent
|
||||
}
|
||||
},
|
||||
onCancel: nil,
|
||||
onDone: { dismiss() },
|
||||
doneButtonText: String(localized: "Done")
|
||||
)
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.onEnded { value in
|
||||
let threshold: CGFloat = 50
|
||||
if value.translation.width < -threshold && selectedPage < pages.count - 1 {
|
||||
withAnimation(.spring(duration: Design.Animation.springDuration)) {
|
||||
selectedPage += 1
|
||||
}
|
||||
} else if value.translation.width > threshold && selectedPage > 0 {
|
||||
withAnimation(.spring(duration: Design.Animation.springDuration)) {
|
||||
selectedPage -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.confirmationDialog(
|
||||
String(localized: "End Session?"),
|
||||
isPresented: $state.showEndSessionConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: "End Session"), role: .destructive) {
|
||||
state.endSessionAndStartNew()
|
||||
dismiss()
|
||||
}
|
||||
Button(String(localized: "Cancel"), role: .cancel) {}
|
||||
} message: {
|
||||
if let session = state.currentSession {
|
||||
Text(String(localized: "You played \(session.roundsPlayed) hands with a net result of \(SessionFormatter.formatMoney(session.netResult)). This session will be saved to your history."))
|
||||
}
|
||||
}
|
||||
.sheet(item: $selectedSession) { session in
|
||||
SessionDetailView(session: session, styleDisplayName: styleDisplayName(for: session.gameStyle))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Page Header
|
||||
// MARK: - Tab Selector
|
||||
|
||||
private var tabSelector: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(StatisticsTab.allCases, id: \.self) { tab in
|
||||
Button {
|
||||
withAnimation(.spring(duration: Design.Animation.springDuration)) {
|
||||
selectedTab = tab
|
||||
}
|
||||
} label: {
|
||||
VStack(spacing: Design.Spacing.xSmall) {
|
||||
Image(systemName: tab.icon)
|
||||
.font(.system(size: Design.BaseFontSize.large))
|
||||
Text(tab.title)
|
||||
.font(.system(size: Design.BaseFontSize.small, weight: .medium))
|
||||
}
|
||||
.foregroundStyle(selectedTab == tab ? Color.Sheet.accent : .white.opacity(Design.Opacity.medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Design.Spacing.medium)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.fill(selectedTab == tab ? Color.Sheet.accent.opacity(Design.Opacity.hint) : .clear)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// MARK: - Current Session Content
|
||||
|
||||
@ViewBuilder
|
||||
private var pageHeader: some View {
|
||||
let currentPage = selectedPage < pages.count ? pages[selectedPage] : .global(StyleStatistics())
|
||||
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
// Left arrow
|
||||
Button {
|
||||
withAnimation(.spring(duration: Design.Animation.springDuration)) {
|
||||
if selectedPage > 0 {
|
||||
selectedPage -= 1
|
||||
}
|
||||
private var currentSessionContent: some View {
|
||||
if let session = state.currentSession {
|
||||
// Current session header
|
||||
CurrentSessionHeader(
|
||||
duration: session.duration,
|
||||
roundsPlayed: session.roundsPlayed,
|
||||
netResult: session.netResult,
|
||||
onEndSession: {
|
||||
state.showEndSessionConfirmation = true
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
|
||||
.foregroundStyle(selectedPage > 0 ? Color.Sheet.accent : Color.white.opacity(Design.Opacity.hint))
|
||||
}
|
||||
.disabled(selectedPage == 0)
|
||||
)
|
||||
.padding(.horizontal)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Page title with icon
|
||||
VStack(spacing: Design.Spacing.xSmall) {
|
||||
Image(systemName: currentPage.icon)
|
||||
.font(.system(size: Design.BaseFontSize.xxLarge))
|
||||
.foregroundStyle(currentPage.accentColor)
|
||||
|
||||
Text(currentPage.title)
|
||||
.font(.system(size: Design.BaseFontSize.title, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Right arrow
|
||||
Button {
|
||||
withAnimation(.spring(duration: Design.Animation.springDuration)) {
|
||||
if selectedPage < pages.count - 1 {
|
||||
selectedPage += 1
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
|
||||
.foregroundStyle(selectedPage < pages.count - 1 ? Color.Sheet.accent : Color.white.opacity(Design.Opacity.hint))
|
||||
}
|
||||
.disabled(selectedPage >= pages.count - 1)
|
||||
// Session stats
|
||||
sessionStatsSection(session: session)
|
||||
} else {
|
||||
noActiveSessionView
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.xLarge)
|
||||
.padding(.top, Design.Spacing.small)
|
||||
}
|
||||
|
||||
// MARK: - Page Indicator
|
||||
private var noActiveSessionView: some View {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
Image(systemName: "play.slash")
|
||||
.font(.system(size: Design.BaseFontSize.xxLarge * 2))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.light))
|
||||
|
||||
Text(String(localized: "No Active Session"))
|
||||
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
|
||||
Text(String(localized: "Start playing to begin tracking your session."))
|
||||
.font(.system(size: Design.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.light))
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(Design.Spacing.xxLarge)
|
||||
}
|
||||
|
||||
private var pageIndicator: some View {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
ForEach(pages.indices, id: \.self) { index in
|
||||
Circle()
|
||||
.fill(index == selectedPage ? Color.Sheet.accent : Color.white.opacity(Design.Opacity.light))
|
||||
.frame(width: Design.Spacing.small, height: Design.Spacing.small)
|
||||
.onTapGesture {
|
||||
withAnimation(.spring(duration: Design.Animation.springDuration)) {
|
||||
selectedPage = index
|
||||
}
|
||||
// MARK: - Global Stats Content
|
||||
|
||||
private var globalStatsContent: some View {
|
||||
let stats = state.aggregatedStats
|
||||
let gameStats = state.aggregatedBlackjackStats
|
||||
|
||||
return Group {
|
||||
// Summary section
|
||||
SheetSection(title: String(localized: "ALL TIME SUMMARY"), icon: "globe") {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
// Sessions overview
|
||||
HStack(spacing: Design.Spacing.large) {
|
||||
StatColumn(
|
||||
value: "\(stats.totalSessions)",
|
||||
label: String(localized: "Sessions")
|
||||
)
|
||||
StatColumn(
|
||||
value: "\(stats.totalRoundsPlayed)",
|
||||
label: String(localized: "Hands")
|
||||
)
|
||||
StatColumn(
|
||||
value: SessionFormatter.formatPercent(stats.winRate),
|
||||
label: String(localized: "Win Rate"),
|
||||
valueColor: stats.winRate >= 50 ? .green : .orange
|
||||
)
|
||||
}
|
||||
|
||||
Divider().background(Color.white.opacity(Design.Opacity.hint))
|
||||
|
||||
// Financial summary
|
||||
HStack(spacing: Design.Spacing.large) {
|
||||
StatColumn(
|
||||
value: SessionFormatter.formatMoney(stats.totalWinnings),
|
||||
label: String(localized: "Net"),
|
||||
valueColor: stats.totalWinnings >= 0 ? .green : .red
|
||||
)
|
||||
StatColumn(
|
||||
value: SessionFormatter.formatDuration(stats.totalPlayTime),
|
||||
label: String(localized: "Time")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Blackjack-specific stats
|
||||
SheetSection(title: String(localized: "GAME STATS"), icon: "suit.spade.fill") {
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
ForEach(gameStats.displayItems) { item in
|
||||
GameStatRow(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Session performance
|
||||
SheetSection(title: String(localized: "SESSION PERFORMANCE"), icon: "chart.bar.fill") {
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
HStack {
|
||||
Text(String(localized: "Winning sessions"))
|
||||
Spacer()
|
||||
Text("\(stats.winningSessions)")
|
||||
.foregroundStyle(.green)
|
||||
.bold()
|
||||
}
|
||||
HStack {
|
||||
Text(String(localized: "Losing sessions"))
|
||||
Spacer()
|
||||
Text("\(stats.losingSessions)")
|
||||
.foregroundStyle(.red)
|
||||
.bold()
|
||||
}
|
||||
|
||||
Divider().background(Color.white.opacity(Design.Opacity.hint))
|
||||
|
||||
HStack {
|
||||
Text(String(localized: "Best session"))
|
||||
Spacer()
|
||||
Text(SessionFormatter.formatMoney(stats.bestSession))
|
||||
.foregroundStyle(.green)
|
||||
.bold()
|
||||
}
|
||||
HStack {
|
||||
Text(String(localized: "Worst session"))
|
||||
Spacer()
|
||||
Text(SessionFormatter.formatMoney(stats.worstSession))
|
||||
.foregroundStyle(.red)
|
||||
.bold()
|
||||
}
|
||||
}
|
||||
.font(.system(size: Design.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Statistics Content
|
||||
// MARK: - Session History Content
|
||||
|
||||
private var sessionHistoryContent: some View {
|
||||
Group {
|
||||
if state.sessionHistory.isEmpty && state.currentSession == nil {
|
||||
emptyHistoryView
|
||||
} else {
|
||||
LazyVStack(spacing: Design.Spacing.medium) {
|
||||
// Current session at top if exists (taps go to Current tab)
|
||||
if let current = state.currentSession {
|
||||
Button {
|
||||
withAnimation {
|
||||
selectedTab = .current
|
||||
}
|
||||
} label: {
|
||||
SessionSummaryRow(
|
||||
styleDisplayName: styleDisplayName(for: current.gameStyle),
|
||||
duration: current.duration,
|
||||
roundsPlayed: current.roundsPlayed,
|
||||
netResult: current.netResult,
|
||||
startTime: current.startTime,
|
||||
isActive: true,
|
||||
endReason: nil
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// Historical sessions - tap to view details
|
||||
ForEach(state.sessionHistory) { session in
|
||||
Button {
|
||||
selectedSession = session
|
||||
} label: {
|
||||
HStack {
|
||||
SessionSummaryRow(
|
||||
styleDisplayName: styleDisplayName(for: session.gameStyle),
|
||||
duration: session.duration,
|
||||
roundsPlayed: session.roundsPlayed,
|
||||
netResult: session.netResult,
|
||||
startTime: session.startTime,
|
||||
isActive: false,
|
||||
endReason: session.endReason
|
||||
)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: Design.BaseFontSize.small, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.light))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyHistoryView: some View {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.font(.system(size: Design.BaseFontSize.xxLarge * 2))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.light))
|
||||
|
||||
Text(String(localized: "No Session History"))
|
||||
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
|
||||
Text(String(localized: "Completed sessions will appear here."))
|
||||
.font(.system(size: Design.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.light))
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(Design.Spacing.xxLarge)
|
||||
}
|
||||
|
||||
// MARK: - Session Stats Section
|
||||
|
||||
@ViewBuilder
|
||||
private func statisticsContent(for page: StatisticsPage) -> some View {
|
||||
let stats = page.statistics
|
||||
|
||||
// In-Game Stats section
|
||||
private func sessionStatsSection(session: BlackjackSession) -> some View {
|
||||
// Game stats section
|
||||
SheetSection(title: String(localized: "IN GAME STATS"), icon: "gamecontroller.fill") {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
// Hands played
|
||||
@ -201,84 +307,68 @@ struct StatisticsSheetView: View {
|
||||
Text(String(localized: "Hands played"))
|
||||
.font(.system(size: Design.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||
Text("\(stats.roundsPlayed)")
|
||||
Text("\(session.roundsPlayed)")
|
||||
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
// Win/Loss/Push distribution
|
||||
// Win/Loss/Push
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
OutcomeCircle(
|
||||
label: String(localized: "Won hands"),
|
||||
count: stats.wins,
|
||||
color: .white
|
||||
)
|
||||
OutcomeCircle(
|
||||
label: String(localized: "Lost hands"),
|
||||
count: stats.losses,
|
||||
color: Color.red
|
||||
)
|
||||
OutcomeCircle(
|
||||
label: String(localized: "Pushed hands"),
|
||||
count: stats.pushes,
|
||||
color: .gray
|
||||
)
|
||||
OutcomeCircle(label: String(localized: "Won"), count: session.wins, color: .white)
|
||||
OutcomeCircle(label: String(localized: "Lost"), count: session.losses, color: .red)
|
||||
OutcomeCircle(label: String(localized: "Push"), count: session.pushes, color: .gray)
|
||||
}
|
||||
|
||||
Divider()
|
||||
.background(Color.white.opacity(Design.Opacity.hint))
|
||||
Divider().background(Color.white.opacity(Design.Opacity.hint))
|
||||
|
||||
// Game time and special outcomes
|
||||
StatRow(icon: "clock", label: String(localized: "Total game time"), value: formatTime(stats.totalPlayTime))
|
||||
StatRow(icon: "21.circle.fill", label: String(localized: "Blackjacks"), value: "\(stats.blackjacks)", valueColor: .yellow)
|
||||
StatRow(icon: "flame.fill", label: String(localized: "Busts"), value: "\(stats.busts)", valueColor: .orange)
|
||||
// Game time and Blackjack-specific stats
|
||||
StatRow(icon: "clock", label: String(localized: "Total game time"), value: SessionFormatter.formatDuration(session.duration))
|
||||
|
||||
if stats.surrenders > 0 {
|
||||
StatRow(icon: "flag.fill", label: String(localized: "Surrenders"), value: "\(stats.surrenders)", valueColor: .gray)
|
||||
ForEach(session.gameStats.displayItems) { item in
|
||||
GameStatRow(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chips Stats section
|
||||
// Chips stats section
|
||||
SheetSection(title: String(localized: "CHIPS STATS"), icon: "dollarsign.circle.fill") {
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
ChipStatRow(
|
||||
icon: "chart.line.uptrend.xyaxis",
|
||||
iconColor: stats.totalWinnings >= 0 ? .green : .red,
|
||||
iconColor: session.totalWinnings >= 0 ? .green : .red,
|
||||
label: String(localized: "Total gain"),
|
||||
value: formatMoney(stats.totalWinnings)
|
||||
value: SessionFormatter.formatMoney(session.totalWinnings)
|
||||
)
|
||||
|
||||
ChipStatRow(
|
||||
icon: "arrow.up.circle.fill",
|
||||
iconColor: .green,
|
||||
label: String(localized: "Best gain"),
|
||||
value: formatMoney(stats.biggestWin)
|
||||
value: SessionFormatter.formatMoney(session.biggestWin)
|
||||
)
|
||||
|
||||
ChipStatRow(
|
||||
icon: "arrow.down.circle.fill",
|
||||
iconColor: .red,
|
||||
label: String(localized: "Worst loss"),
|
||||
value: formatMoney(stats.biggestLoss)
|
||||
value: SessionFormatter.formatMoney(session.biggestLoss)
|
||||
)
|
||||
|
||||
Divider()
|
||||
.background(Color.white.opacity(Design.Opacity.hint))
|
||||
Divider().background(Color.white.opacity(Design.Opacity.hint))
|
||||
|
||||
ChipStatRow(
|
||||
icon: "plusminus.circle.fill",
|
||||
iconColor: .blue,
|
||||
label: String(localized: "Total bet"),
|
||||
value: "$\(stats.totalBetAmount)"
|
||||
value: "$\(session.totalBetAmount)"
|
||||
)
|
||||
|
||||
if stats.roundsPlayed > 0 {
|
||||
if session.roundsPlayed > 0 {
|
||||
ChipStatRow(
|
||||
icon: "equal.circle.fill",
|
||||
iconColor: .purple,
|
||||
label: String(localized: "Average bet"),
|
||||
value: "$\(stats.totalBetAmount / stats.roundsPlayed)"
|
||||
value: "$\(session.averageBet)"
|
||||
)
|
||||
}
|
||||
|
||||
@ -286,101 +376,63 @@ struct StatisticsSheetView: View {
|
||||
icon: "star.circle.fill",
|
||||
iconColor: .orange,
|
||||
label: String(localized: "Biggest bet"),
|
||||
value: "$\(stats.biggestBet)"
|
||||
value: "$\(session.biggestBet)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Formatters
|
||||
// MARK: - Helpers
|
||||
|
||||
private func formatMoney(_ amount: Int) -> String {
|
||||
if amount >= 0 {
|
||||
return "$\(amount)"
|
||||
} else {
|
||||
return "-$\(abs(amount))"
|
||||
}
|
||||
}
|
||||
|
||||
private func formatTime(_ seconds: TimeInterval) -> String {
|
||||
let hours = Int(seconds) / 3600
|
||||
let minutes = (Int(seconds) % 3600) / 60
|
||||
return String(format: "%02dh %02dmin", hours, minutes)
|
||||
private func styleDisplayName(for rawValue: String) -> String {
|
||||
BlackjackStyle(rawValue: rawValue)?.displayName ?? rawValue.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Statistics Page Type
|
||||
// MARK: - Statistics Tab
|
||||
|
||||
private enum StatisticsPage: Identifiable {
|
||||
case global(StyleStatistics)
|
||||
case style(BlackjackStyle, StyleStatistics)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .global:
|
||||
return "global"
|
||||
case .style(let style, _):
|
||||
return style.rawValue
|
||||
}
|
||||
}
|
||||
private enum StatisticsTab: CaseIterable {
|
||||
case current
|
||||
case global
|
||||
case history
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .global:
|
||||
return String(localized: "GLOBAL")
|
||||
case .style(let style, _):
|
||||
return style.displayName.uppercased()
|
||||
case .current: return String(localized: "Current")
|
||||
case .global: return String(localized: "Global")
|
||||
case .history: return String(localized: "History")
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .global:
|
||||
return "globe"
|
||||
case .style(let style, _):
|
||||
switch style {
|
||||
case .vegas:
|
||||
return "building.2.fill"
|
||||
case .atlantic:
|
||||
return "water.waves"
|
||||
case .european:
|
||||
return "flag.fill"
|
||||
case .custom:
|
||||
return "slider.horizontal.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var accentColor: Color {
|
||||
switch self {
|
||||
case .global:
|
||||
return Color.Sheet.accent
|
||||
case .style(let style, _):
|
||||
switch style {
|
||||
case .vegas:
|
||||
return .orange
|
||||
case .atlantic:
|
||||
return .cyan
|
||||
case .european:
|
||||
return .blue
|
||||
case .custom:
|
||||
return .purple
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var statistics: StyleStatistics {
|
||||
switch self {
|
||||
case .global(let stats):
|
||||
return stats
|
||||
case .style(_, let stats):
|
||||
return stats
|
||||
case .current: return "play.circle.fill"
|
||||
case .global: return "globe"
|
||||
case .history: return "clock.arrow.circlepath"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Views
|
||||
|
||||
private struct StatColumn: View {
|
||||
let value: String
|
||||
let label: String
|
||||
var valueColor: Color = .white
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.xSmall) {
|
||||
Text(value)
|
||||
.font(.system(size: Design.BaseFontSize.large, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(valueColor)
|
||||
Text(label)
|
||||
.font(.system(size: Design.BaseFontSize.small))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private struct OutcomeCircle: View {
|
||||
let label: String
|
||||
let count: Int
|
||||
@ -405,7 +457,6 @@ private struct OutcomeCircle: View {
|
||||
.stroke(color, lineWidth: Design.LineWidth.thick)
|
||||
.frame(width: Size.outcomeCircleSize, height: Size.outcomeCircleSize)
|
||||
|
||||
// Inner filled circle
|
||||
Circle()
|
||||
.fill(color.opacity(Design.Opacity.medium))
|
||||
.frame(width: Size.outcomeCircleInner, height: Size.outcomeCircleInner)
|
||||
@ -449,7 +500,6 @@ private struct ChipStatRow: View {
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
// Chip-style icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(iconColor)
|
||||
@ -473,6 +523,174 @@ private struct ChipStatRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session Detail View
|
||||
|
||||
private struct SessionDetailView: View {
|
||||
let session: BlackjackSession
|
||||
let styleDisplayName: String
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
SheetContainerView(
|
||||
title: styleDisplayName,
|
||||
content: {
|
||||
// Session header info
|
||||
sessionHeader
|
||||
|
||||
// Game stats section
|
||||
SheetSection(title: String(localized: "GAME STATS"), icon: "gamecontroller.fill") {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
// Hands played
|
||||
VStack(spacing: Design.Spacing.xSmall) {
|
||||
Text(String(localized: "Hands played"))
|
||||
.font(.system(size: Design.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||
Text("\(session.roundsPlayed)")
|
||||
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
// Win/Loss/Push
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
OutcomeCircle(label: String(localized: "Won"), count: session.wins, color: .white)
|
||||
OutcomeCircle(label: String(localized: "Lost"), count: session.losses, color: .red)
|
||||
OutcomeCircle(label: String(localized: "Push"), count: session.pushes, color: .gray)
|
||||
}
|
||||
|
||||
Divider().background(Color.white.opacity(Design.Opacity.hint))
|
||||
|
||||
// Game time
|
||||
StatRow(icon: "clock", label: String(localized: "Total game time"), value: SessionFormatter.formatDuration(session.duration))
|
||||
|
||||
// Blackjack-specific stats
|
||||
ForEach(session.gameStats.displayItems) { item in
|
||||
GameStatRow(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chips stats section
|
||||
SheetSection(title: String(localized: "CHIPS STATS"), icon: "dollarsign.circle.fill") {
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
ChipStatRow(
|
||||
icon: "chart.line.uptrend.xyaxis",
|
||||
iconColor: session.totalWinnings >= 0 ? .green : .red,
|
||||
label: String(localized: "Net result"),
|
||||
value: SessionFormatter.formatMoney(session.totalWinnings)
|
||||
)
|
||||
|
||||
ChipStatRow(
|
||||
icon: "arrow.up.circle.fill",
|
||||
iconColor: .green,
|
||||
label: String(localized: "Best gain"),
|
||||
value: SessionFormatter.formatMoney(session.biggestWin)
|
||||
)
|
||||
|
||||
ChipStatRow(
|
||||
icon: "arrow.down.circle.fill",
|
||||
iconColor: .red,
|
||||
label: String(localized: "Worst loss"),
|
||||
value: SessionFormatter.formatMoney(session.biggestLoss)
|
||||
)
|
||||
|
||||
Divider().background(Color.white.opacity(Design.Opacity.hint))
|
||||
|
||||
ChipStatRow(
|
||||
icon: "plusminus.circle.fill",
|
||||
iconColor: .blue,
|
||||
label: String(localized: "Total bet"),
|
||||
value: "$\(session.totalBetAmount)"
|
||||
)
|
||||
|
||||
if session.roundsPlayed > 0 {
|
||||
ChipStatRow(
|
||||
icon: "equal.circle.fill",
|
||||
iconColor: .purple,
|
||||
label: String(localized: "Average bet"),
|
||||
value: "$\(session.averageBet)"
|
||||
)
|
||||
}
|
||||
|
||||
ChipStatRow(
|
||||
icon: "star.circle.fill",
|
||||
iconColor: .orange,
|
||||
label: String(localized: "Biggest bet"),
|
||||
value: "$\(session.biggestBet)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Balance section
|
||||
SheetSection(title: String(localized: "BALANCE"), icon: "banknote.fill") {
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
HStack {
|
||||
Text(String(localized: "Starting balance"))
|
||||
Spacer()
|
||||
Text("$\(session.startingBalance)")
|
||||
.bold()
|
||||
}
|
||||
HStack {
|
||||
Text(String(localized: "Ending balance"))
|
||||
Spacer()
|
||||
Text("$\(session.endingBalance)")
|
||||
.foregroundStyle(session.netResult >= 0 ? .green : .red)
|
||||
.bold()
|
||||
}
|
||||
}
|
||||
.font(.system(size: Design.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||
}
|
||||
},
|
||||
onCancel: nil,
|
||||
onDone: { dismiss() },
|
||||
doneButtonText: String(localized: "Done")
|
||||
)
|
||||
}
|
||||
|
||||
private var sessionHeader: some View {
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
// Date and duration
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||
Text(SessionFormatter.formatSessionDate(session.startTime))
|
||||
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if let endReason = session.endReason {
|
||||
HStack(spacing: Design.Spacing.xSmall) {
|
||||
Image(systemName: endReason == .brokeOut ? "xmark.circle.fill" : "checkmark.circle.fill")
|
||||
.foregroundStyle(endReason == .brokeOut ? .red : .green)
|
||||
Text(endReason == .brokeOut ? String(localized: "Ran out of chips") : String(localized: "Ended manually"))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
}
|
||||
.font(.system(size: Design.BaseFontSize.small))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Net result badge
|
||||
VStack(alignment: .trailing, spacing: Design.Spacing.xxSmall) {
|
||||
Text(SessionFormatter.formatMoney(session.netResult))
|
||||
.font(.system(size: Design.BaseFontSize.xLarge, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(session.netResult >= 0 ? .green : .red)
|
||||
|
||||
Text(SessionFormatter.formatPercent(session.winRate) + " " + String(localized: "win rate"))
|
||||
.font(.system(size: Design.BaseFontSize.small))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.fill(Color.Sheet.sectionFill)
|
||||
)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Local Size Constants
|
||||
|
||||
private enum Size {
|
||||
|
||||
@ -56,11 +56,24 @@ Professional-grade tools for learning the Hi-Lo system:
|
||||
- **Betting Hints** — Recommendations based on count advantage
|
||||
- **Illustrious 18** — Count-adjusted strategy deviations
|
||||
|
||||
### 📊 Statistics Tracking
|
||||
- Win rate, blackjack count, bust rate
|
||||
- Session net profit/loss
|
||||
- Biggest wins and losses
|
||||
- Complete round history
|
||||
### 📊 Session-Based Statistics
|
||||
Track your play sessions like a real casino visit:
|
||||
|
||||
- **Current Session** — Live stats for your active session
|
||||
- **Global Stats** — Aggregated lifetime statistics
|
||||
- **Session History** — Review past sessions with detailed breakdowns
|
||||
|
||||
**Per-Session Tracking:**
|
||||
- Duration and hands played
|
||||
- Win/loss/push breakdown
|
||||
- Net result and win rate
|
||||
- Blackjacks, busts, doubles, splits
|
||||
- Average and biggest bets
|
||||
|
||||
**Session Management:**
|
||||
- End a session manually or when you run out of chips
|
||||
- Stats persisted across game styles (Vegas Strip, Atlantic City, etc.)
|
||||
- Complete round history within each session
|
||||
|
||||
### ☁️ iCloud Sync
|
||||
- Balance and statistics sync across devices
|
||||
@ -82,7 +95,7 @@ Blackjack/
|
||||
│ ├── Hand.swift # BlackjackHand model
|
||||
│ └── SideBet.swift # Side bet types and evaluation
|
||||
├── Storage/
|
||||
│ └── BlackjackGameData.swift # Persistence models
|
||||
│ └── BlackjackGameData.swift # Persistence and session models
|
||||
├── Theme/
|
||||
│ └── DesignConstants.swift # Design system tokens
|
||||
├── Views/
|
||||
|
||||
@ -479,6 +479,91 @@ sound.hapticError() // Error notification
|
||||
sound.hapticWarning() // Warning notification
|
||||
```
|
||||
|
||||
### 📊 Session Management
|
||||
|
||||
**GameSession** - Track play sessions with common and game-specific stats.
|
||||
|
||||
```swift
|
||||
// Create a game-specific stats type
|
||||
struct MyGameStats: GameSpecificStats {
|
||||
var specialWins: Int = 0
|
||||
|
||||
init() {}
|
||||
|
||||
var displayItems: [StatDisplayItem] {
|
||||
[StatDisplayItem(icon: "star.fill", iconColor: .yellow,
|
||||
label: "Special Wins", value: "\(specialWins)")]
|
||||
}
|
||||
}
|
||||
|
||||
// Create session type alias
|
||||
typealias MyGameSession = GameSession<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
|
||||
|
||||
**CloudSyncManager** - Saves game data locally and syncs with iCloud.
|
||||
@ -681,6 +766,7 @@ CasinoKit/
|
||||
├── Package.swift
|
||||
├── README.md
|
||||
├── GAME_TEMPLATE.md # Guide for creating new games
|
||||
├── SESSION_SYSTEM.md # Session tracking documentation
|
||||
├── Sources/CasinoKit/
|
||||
│ ├── CasinoKit.swift
|
||||
│ ├── Exports.swift
|
||||
@ -690,7 +776,11 @@ CasinoKit/
|
||||
│ │ ├── ChipDenomination.swift
|
||||
│ │ ├── TableLimits.swift # Betting limit presets
|
||||
│ │ ├── OnboardingState.swift # Onboarding tracking
|
||||
│ │ └── TooltipManager.swift # Tooltip management
|
||||
│ │ ├── TooltipManager.swift # Tooltip management
|
||||
│ │ └── Session/
|
||||
│ │ ├── GameSession.swift # Generic session with stats
|
||||
│ │ ├── GameSessionProtocol.swift # Session protocols
|
||||
│ │ └── SessionFormatter.swift # Formatting utilities
|
||||
│ ├── Views/
|
||||
│ │ ├── Cards/
|
||||
│ │ │ ├── CardView.swift
|
||||
@ -724,8 +814,10 @@ CasinoKit/
|
||||
│ │ │ └── ActionButton.swift # Deal/Hit/Stand buttons
|
||||
│ │ ├── Zones/
|
||||
│ │ │ └── BettingZone.swift # Tappable betting area
|
||||
│ │ └── Settings/
|
||||
│ │ └── SettingsComponents.swift # Toggle, pickers
|
||||
│ │ ├── Settings/
|
||||
│ │ │ └── SettingsComponents.swift # Toggle, pickers
|
||||
│ │ └── Session/
|
||||
│ │ └── SessionViews.swift # Session UI components
|
||||
│ ├── Audio/
|
||||
│ │ └── SoundManager.swift
|
||||
│ ├── Storage/
|
||||
@ -793,6 +885,13 @@ private var fontSize: CGFloat = CasinoDesign.BaseFontSize.body
|
||||
|
||||
## Version History
|
||||
|
||||
- **1.1.0** - Session system
|
||||
- Generic `GameSession<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
|
||||
- Card and Chip components
|
||||
- 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
|
||||
// - 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
|
||||
// - 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" : {
|
||||
"comment" : "Hint shown when bet is below table minimum. The argument is the dollar amount needed.",
|
||||
"localizations" : {
|
||||
@ -478,6 +482,14 @@
|
||||
"comment" : "The accessibility label for the betting hint view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Bust" : {
|
||||
"comment" : "A string describing when a player busts out of a game.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Cancel" : {
|
||||
"comment" : "A button label that cancels an action.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Card face down" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -659,6 +671,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Current Session" : {
|
||||
"comment" : "A label for the header of the current session section.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Data Storage" : {
|
||||
"comment" : "Title of a section in the Privacy Policy View that discusses how game data is stored.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@ -823,6 +839,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Duration" : {
|
||||
"comment" : "A label displayed next to the duration of a session",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Eight" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -867,6 +887,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"End Session" : {
|
||||
"comment" : "A button label that says \"End Session\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"End Session?" : {
|
||||
"comment" : "A title for the confirmation dialog that asks if the user is sure they want to end their session.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Ended" : {
|
||||
"comment" : "A label indicating that a session has ended.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Exclusive VIP room" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -1029,6 +1061,10 @@
|
||||
},
|
||||
"Got it" : {
|
||||
|
||||
},
|
||||
"Hands" : {
|
||||
"comment" : "Label for the number of hands played in the current session.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Hearts" : {
|
||||
"localizations" : {
|
||||
@ -1425,6 +1461,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Net" : {
|
||||
"comment" : "Label for the net result in the current session header.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Net Result" : {
|
||||
|
||||
},
|
||||
"New Round" : {
|
||||
"comment" : "A button label that initiates a new round of a casino game.",
|
||||
@ -2273,6 +2316,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Your session will be saved to history and a new session will start." : {
|
||||
"comment" : "A piece of information displayed in the confirmation dialog, explaining that the user's session will be saved and a new one will begin.",
|
||||
"isCommentAutoGenerated" : true
|
||||
}
|
||||
},
|
||||
"version" : "1.1"
|
||||
|
||||
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