Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
178d28ca6c
commit
abf4ba9b97
@ -45,12 +45,14 @@ struct BetResult: Identifiable {
|
||||
}
|
||||
|
||||
/// Main observable game state class managing all game logic and UI state.
|
||||
/// Conforms to CasinoGameState for shared game behaviors.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class GameState: SessionManagedGame {
|
||||
// MARK: - SessionManagedGame
|
||||
final class GameState: CasinoGameState {
|
||||
// MARK: - CasinoGameState Conformance
|
||||
|
||||
typealias Stats = BaccaratStats
|
||||
typealias GameSettingsType = GameSettings
|
||||
|
||||
/// The currently active session.
|
||||
var currentSession: BaccaratSession?
|
||||
|
||||
30
Baccarat/Baccarat/Models/Card+Baccarat.swift
Normal file
30
Baccarat/Baccarat/Models/Card+Baccarat.swift
Normal file
@ -0,0 +1,30 @@
|
||||
//
|
||||
// Card+Baccarat.swift
|
||||
// Baccarat
|
||||
//
|
||||
// Baccarat-specific card value extensions.
|
||||
//
|
||||
|
||||
import CasinoKit
|
||||
|
||||
extension Rank {
|
||||
/// The baccarat point value of this rank.
|
||||
/// Ace = 1, 2-9 = face value, 10/J/Q/K = 0.
|
||||
var baccaratValue: Int {
|
||||
switch self {
|
||||
case .ace: return 1
|
||||
case .two, .three, .four, .five, .six, .seven, .eight, .nine:
|
||||
return rawValue
|
||||
case .ten, .jack, .queen, .king:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Card {
|
||||
/// The baccarat point value of this card.
|
||||
var baccaratValue: Int {
|
||||
rank.baccaratValue
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import CasinoKit
|
||||
|
||||
/// The number of decks available for the shoe.
|
||||
enum DeckCount: Int, CaseIterable, Identifiable {
|
||||
@ -33,65 +34,13 @@ enum DeckCount: Int, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Preset table limits for betting.
|
||||
enum TableLimits: String, CaseIterable, Identifiable {
|
||||
case casual = "casual"
|
||||
case low = "low"
|
||||
case medium = "medium"
|
||||
case high = "high"
|
||||
case vip = "vip"
|
||||
// TableLimits is now provided by CasinoKit
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .casual: return "Casual"
|
||||
case .low: return "Low Stakes"
|
||||
case .medium: return "Medium Stakes"
|
||||
case .high: return "High Stakes"
|
||||
case .vip: return "VIP"
|
||||
}
|
||||
}
|
||||
|
||||
var minBet: Int {
|
||||
switch self {
|
||||
case .casual: return 5
|
||||
case .low: return 10
|
||||
case .medium: return 25
|
||||
case .high: return 100
|
||||
case .vip: return 500
|
||||
}
|
||||
}
|
||||
|
||||
var maxBet: Int {
|
||||
switch self {
|
||||
case .casual: return 500
|
||||
case .low: return 1_000
|
||||
case .medium: return 5_000
|
||||
case .high: return 10_000
|
||||
case .vip: return 50_000
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
"$\(minBet) - $\(maxBet.formatted())"
|
||||
}
|
||||
|
||||
var detailedDescription: String {
|
||||
switch self {
|
||||
case .casual: return "Perfect for learning"
|
||||
case .low: return "Standard mini baccarat"
|
||||
case .medium: return "Regular casino table"
|
||||
case .high: return "High roller table"
|
||||
case .vip: return "Exclusive VIP room"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Observable settings class for game configuration.
|
||||
/// Observable settings class for Baccarat configuration.
|
||||
/// Conforms to GameSettingsProtocol for shared settings behavior.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class GameSettings {
|
||||
final class GameSettings: GameSettingsProtocol {
|
||||
// MARK: - Deck Settings
|
||||
|
||||
/// Number of decks in the shoe.
|
||||
|
||||
@ -354,9 +354,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$%lld" : {
|
||||
|
||||
},
|
||||
"2-9: Face value" : {
|
||||
"comment" : "Description of the card values for cards with values from 2 to 9.",
|
||||
@ -684,10 +681,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Average bet" : {
|
||||
"comment" : "The value of this row is calculated as the total bet amount divided by the number of rounds played.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Avoid the Tie bet — 14.4% house edge!" : {
|
||||
"comment" : "Tip for avoiding the Tie bet in baccarat, highlighting its low house edge.",
|
||||
"localizations" : {
|
||||
@ -1031,14 +1024,6 @@
|
||||
"comment" : "Label for the number of banker win rounds in the statistics display.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Best gain" : {
|
||||
"comment" : "\"Best gain\" is a colloquial term for the largest positive winnings in a single game.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Best session" : {
|
||||
"comment" : "A label for the best session amount in the statistics sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Bet on Player, Banker, or Tie" : {
|
||||
|
||||
},
|
||||
@ -1072,10 +1057,6 @@
|
||||
"comment" : "Title for the section in the statistics sheet that shows the user's performance on the Big Road.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Biggest bet" : {
|
||||
"comment" : "The label for the \"Biggest bet\" row in the statistics sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Blackjack" : {
|
||||
"comment" : "The name of a blackjack game.",
|
||||
"localizations" : {
|
||||
@ -1382,13 +1363,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Completed sessions will appear here." : {
|
||||
"comment" : "A description below the label indicating that completed sessions will be displayed here.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Current" : {
|
||||
|
||||
},
|
||||
"Customize Settings" : {
|
||||
|
||||
@ -1489,9 +1463,6 @@
|
||||
"Delete" : {
|
||||
"comment" : "A button to delete a session.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Delete Session" : {
|
||||
|
||||
},
|
||||
"Delete Session?" : {
|
||||
|
||||
@ -1644,13 +1615,6 @@
|
||||
"End Session?" : {
|
||||
"comment" : "A confirmation dialog title.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Ended manually" : {
|
||||
"comment" : "A description of a session that ended manually, rather than automatically.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Ending balance" : {
|
||||
|
||||
},
|
||||
"Example: 5♥ + 5♣ = Pair (wins!)" : {
|
||||
"comment" : "Example of a pair bet winning.",
|
||||
@ -1819,10 +1783,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Global" : {
|
||||
"comment" : "Title of the statistics tab that shows global statistics.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Green Circle (T): Tie between Player and Banker" : {
|
||||
"comment" : "Explains the green circle icon in the history.",
|
||||
"localizations" : {
|
||||
@ -1919,10 +1879,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"History" : {
|
||||
"comment" : "Title of the statistics tab that shows the user's session history.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"HISTORY" : {
|
||||
"comment" : "A label displayed above the road map view, indicating that it shows a history of past game results.",
|
||||
"localizations" : {
|
||||
@ -2332,10 +2288,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Losing sessions" : {
|
||||
"comment" : "A label describing the number of sessions the user has lost.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Lost" : {
|
||||
"comment" : "Labels for the outcome circles in the \"Win/Loss/Push\" section.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -2480,9 +2432,6 @@
|
||||
"Net" : {
|
||||
"comment" : "Label for the net winnings in the statistics sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Net result" : {
|
||||
|
||||
},
|
||||
"Never" : {
|
||||
"localizations" : {
|
||||
@ -2529,10 +2478,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"No Active Session" : {
|
||||
"comment" : "A message displayed when there is no active session to display in the statistics sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No cards" : {
|
||||
"comment" : "A description of the player's hand when they have no cards.",
|
||||
"localizations" : {
|
||||
@ -2601,10 +2546,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"No Session History" : {
|
||||
"comment" : "A description displayed when a user has no session history.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Objective" : {
|
||||
"comment" : "Title of a rule page in the \"Rules\" help view, describing the objective of the game.",
|
||||
"localizations" : {
|
||||
@ -3126,10 +3067,6 @@
|
||||
"comment" : "A label for the \"Push\" outcome in the game stats section.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Ran out of chips" : {
|
||||
"comment" : "A description of why a session might have ended with a \"Ran out of chips\" result.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Red Circle (B): Banker won the hand" : {
|
||||
"comment" : "Explains the red circle icon in the history.",
|
||||
"localizations" : {
|
||||
@ -3529,15 +3466,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Start playing to begin tracking your session." : {
|
||||
"comment" : "A description below the \"No Active Session\" label, instructing the user to start playing to view their session statistics.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Start with $1,000 and play risk-free" : {
|
||||
|
||||
},
|
||||
"Starting balance" : {
|
||||
|
||||
},
|
||||
"STARTING BALANCE" : {
|
||||
"comment" : "Section header for starting balance settings.",
|
||||
@ -3979,14 +3909,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Total bet" : {
|
||||
"comment" : "The value string for the \"Total bet\" row in the statistics sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Total gain" : {
|
||||
"comment" : "Label for the total gain in the statistics sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Total game time" : {
|
||||
"comment" : "Rows in the \"Game stats\" section of the statistics sheet, showing various statistics about a Baccarat session.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -4269,10 +4191,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"win rate" : {
|
||||
"comment" : "A label describing the win rate of a session.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Win Rate" : {
|
||||
"comment" : "Label for the win rate in the statistics sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -4300,22 +4218,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Winning sessions" : {
|
||||
"comment" : "A title describing the number of sessions the user has won.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Won" : {
|
||||
"comment" : "Labels for the outcome circles in the \"Win/Loss/Push\" section.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Worst loss" : {
|
||||
"comment" : "The label and value for the \"Worst loss\" row are identical to those for the \"Best gain\" row. This is intentional, as it highlights the symmetry in the data.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Worst session" : {
|
||||
"comment" : "A label for the worst session amount in the statistics sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Yellow Dot (bottom-left): A pair occurred in that hand" : {
|
||||
"comment" : "Explains the yellow dot marker in the history.",
|
||||
"localizations" : {
|
||||
|
||||
40
Baccarat/Baccarat/Theme/BrandingConfig+Baccarat.swift
Normal file
40
Baccarat/Baccarat/Theme/BrandingConfig+Baccarat.swift
Normal file
@ -0,0 +1,40 @@
|
||||
//
|
||||
// BrandingConfig+Baccarat.swift
|
||||
// Baccarat
|
||||
//
|
||||
// Baccarat-specific branding configurations for AppIconView and LaunchScreenView.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CasinoKit
|
||||
|
||||
extension AppIconConfig {
|
||||
/// Baccarat game icon configuration.
|
||||
static let baccarat = AppIconConfig(
|
||||
title: "BACCARAT",
|
||||
iconSymbol: "suit.spade.fill"
|
||||
)
|
||||
}
|
||||
|
||||
extension LaunchScreenConfig {
|
||||
/// Baccarat game launch screen configuration.
|
||||
static let baccarat = LaunchScreenConfig(
|
||||
title: "BACCARAT",
|
||||
tagline: "The Classic Casino Card Game",
|
||||
iconSymbols: ["suit.spade.fill", "suit.heart.fill"]
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - For Development Preview Comparison
|
||||
|
||||
extension AppIconConfig {
|
||||
/// Blackjack config for side-by-side comparison in dev previews.
|
||||
static let blackjack = AppIconConfig(
|
||||
title: "BLACKJACK",
|
||||
subtitle: "21",
|
||||
iconSymbol: "suit.club.fill",
|
||||
primaryColor: Color(red: 0.05, green: 0.35, blue: 0.15),
|
||||
secondaryColor: Color(red: 0.03, green: 0.2, blue: 0.1)
|
||||
)
|
||||
}
|
||||
|
||||
@ -136,6 +136,7 @@ struct GameTableView: View {
|
||||
.sheet(isPresented: $showWelcome) {
|
||||
WelcomeSheet(
|
||||
gameName: "Baccarat",
|
||||
gameEmoji: "🎴",
|
||||
features: [
|
||||
WelcomeFeature(
|
||||
icon: "hand.raised.fill",
|
||||
|
||||
@ -19,8 +19,8 @@ struct StatisticsSheetView: View {
|
||||
SheetContainerView(
|
||||
title: String(localized: "Statistics"),
|
||||
content: {
|
||||
// Tab selector
|
||||
tabSelector
|
||||
// Tab selector (from CasinoKit)
|
||||
StatisticsTabSelector(selectedTab: $selectedTab)
|
||||
|
||||
// Content based on selected tab
|
||||
switch selectedTab {
|
||||
@ -67,34 +67,7 @@ struct StatisticsSheetView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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: - Tab Selector (uses CasinoKit.StatisticsTabSelector)
|
||||
|
||||
// MARK: - Current Session Content
|
||||
|
||||
@ -119,28 +92,10 @@ struct StatisticsSheetView: View {
|
||||
bigRoadSection
|
||||
roadMapSection
|
||||
} else {
|
||||
noActiveSessionView
|
||||
NoActiveSessionView()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// MARK: - Global Stats Content
|
||||
|
||||
private var globalStatsContent: some View {
|
||||
@ -215,43 +170,14 @@ struct StatisticsSheetView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Session performance
|
||||
// Session performance (from CasinoKit)
|
||||
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))
|
||||
SessionPerformanceSection(
|
||||
winningSessions: stats.winningSessions,
|
||||
losingSessions: stats.losingSessions,
|
||||
bestSession: stats.bestSession,
|
||||
worstSession: stats.worstSession
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -261,7 +187,7 @@ struct StatisticsSheetView: View {
|
||||
private var sessionHistoryContent: some View {
|
||||
Group {
|
||||
if state.sessionHistory.isEmpty && state.currentSession == nil {
|
||||
emptyHistoryView
|
||||
EmptyHistoryView()
|
||||
} else {
|
||||
LazyVStack(spacing: Design.Spacing.medium) {
|
||||
// Current session at top if exists (taps go to Current tab)
|
||||
@ -322,23 +248,6 @@ struct StatisticsSheetView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@ -396,55 +305,16 @@ struct StatisticsSheetView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Chips stats section
|
||||
// Chips stats section (from CasinoKit)
|
||||
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: "Total gain"),
|
||||
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)"
|
||||
)
|
||||
}
|
||||
ChipsStatsSection(
|
||||
totalWinnings: session.totalWinnings,
|
||||
biggestWin: session.biggestWin,
|
||||
biggestLoss: session.biggestLoss,
|
||||
totalBetAmount: session.totalBetAmount,
|
||||
averageBet: session.roundsPlayed > 0 ? session.averageBet : nil,
|
||||
biggestBet: session.biggestBet
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -501,163 +371,37 @@ struct StatisticsSheetView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Statistics Tab
|
||||
// MARK: - Statistics Tab & Supporting Views
|
||||
// StatisticsTab, StatColumn, OutcomeCircle, StatRow, ChipStatRow are now provided by CasinoKit
|
||||
|
||||
private enum StatisticsTab: CaseIterable {
|
||||
case current
|
||||
case global
|
||||
case history
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .current: return String(localized: "Current")
|
||||
case .global: return String(localized: "Global")
|
||||
case .history: return String(localized: "History")
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
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
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
Text(label)
|
||||
.font(.system(size: Design.BaseFontSize.small))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
|
||||
Text("\(count)")
|
||||
.font(.system(size: Design.BaseFontSize.large, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(Design.Opacity.light))
|
||||
.frame(width: Size.outcomeCircleSize, height: Size.outcomeCircleSize)
|
||||
|
||||
Circle()
|
||||
.stroke(color, lineWidth: Design.LineWidth.thick)
|
||||
.frame(width: Size.outcomeCircleSize, height: Size.outcomeCircleSize)
|
||||
|
||||
Circle()
|
||||
.fill(color.opacity(Design.Opacity.medium))
|
||||
.frame(width: Size.outcomeCircleInner, height: Size.outcomeCircleInner)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
// MARK: - Baccarat-Specific Views
|
||||
|
||||
/// Compact win distribution indicator for Player/Banker/Tie.
|
||||
private struct WinStatCompact: View {
|
||||
let label: String
|
||||
let count: Int
|
||||
let color: Color
|
||||
|
||||
private let indicatorSize: CGFloat = 24
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.xSmall) {
|
||||
VStack(spacing: CasinoDesign.Spacing.xSmall) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: Size.winIndicatorSize, height: Size.winIndicatorSize)
|
||||
.frame(width: indicatorSize, height: indicatorSize)
|
||||
|
||||
Text("\(count)")
|
||||
.font(.system(size: Design.BaseFontSize.large, weight: .bold))
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.large, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: Design.BaseFontSize.small))
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.small))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatRow: View {
|
||||
let icon: String
|
||||
let label: String
|
||||
let value: String
|
||||
var valueColor: Color = .white
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: Design.BaseFontSize.large))
|
||||
.foregroundStyle(Color.Sheet.accent)
|
||||
.frame(width: Size.statIconWidth)
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: Design.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: Design.BaseFontSize.body, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(valueColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChipStatRow: View {
|
||||
let icon: String
|
||||
let iconColor: Color
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(iconColor)
|
||||
.frame(width: Size.chipIconSize, height: Size.chipIconSize)
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: Design.BaseFontSize.small, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: Design.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: Design.BaseFontSize.body, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(Color.Sheet.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session Detail View
|
||||
|
||||
private struct SessionDetailView: View {
|
||||
@ -673,8 +417,13 @@ private struct SessionDetailView: View {
|
||||
SheetContainerView(
|
||||
title: styleDisplayName,
|
||||
content: {
|
||||
// Session header info
|
||||
sessionHeader
|
||||
// Session header info (from CasinoKit)
|
||||
SessionDetailHeader(
|
||||
startTime: session.startTime,
|
||||
endReason: session.endReason,
|
||||
netResult: session.netResult,
|
||||
winRate: session.winRate
|
||||
)
|
||||
|
||||
// Game stats section
|
||||
SheetSection(title: String(localized: "GAME STATS"), icon: "gamecontroller.fill") {
|
||||
@ -689,7 +438,7 @@ private struct SessionDetailView: View {
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
// Win/Loss/Push
|
||||
// Win/Loss/Push (from CasinoKit)
|
||||
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)
|
||||
@ -698,7 +447,7 @@ private struct SessionDetailView: View {
|
||||
|
||||
Divider().background(Color.white.opacity(Design.Opacity.hint))
|
||||
|
||||
// Game time
|
||||
// Game time (from CasinoKit)
|
||||
StatRow(icon: "clock", label: String(localized: "Total game time"), value: SessionFormatter.formatDuration(session.duration))
|
||||
|
||||
// Baccarat-specific stats
|
||||
@ -708,7 +457,7 @@ private struct SessionDetailView: View {
|
||||
|
||||
Divider().background(Color.white.opacity(Design.Opacity.hint))
|
||||
|
||||
// Win distribution
|
||||
// Win distribution (Baccarat-specific)
|
||||
HStack(spacing: Design.Spacing.large) {
|
||||
WinStatCompact(
|
||||
label: String(localized: "Player"),
|
||||
@ -729,79 +478,28 @@ private struct SessionDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Chips stats section
|
||||
// Chips stats section (from CasinoKit)
|
||||
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)"
|
||||
)
|
||||
}
|
||||
ChipsStatsSection(
|
||||
totalWinnings: session.totalWinnings,
|
||||
biggestWin: session.biggestWin,
|
||||
biggestLoss: session.biggestLoss,
|
||||
totalBetAmount: session.totalBetAmount,
|
||||
averageBet: session.roundsPlayed > 0 ? session.averageBet : nil,
|
||||
biggestBet: session.biggestBet
|
||||
)
|
||||
}
|
||||
|
||||
// Balance section
|
||||
// Balance section (from CasinoKit)
|
||||
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))
|
||||
BalanceSection(
|
||||
startingBalance: session.startingBalance,
|
||||
endingBalance: session.endingBalance,
|
||||
netResult: session.netResult
|
||||
)
|
||||
}
|
||||
|
||||
// Big Road section
|
||||
// Big Road section (Baccarat-specific)
|
||||
if !roundHistory.isEmpty {
|
||||
SheetSection(title: String(localized: "BIG ROAD"), icon: "chart.bar.xaxis") {
|
||||
BigRoadView(results: roundHistory)
|
||||
@ -827,25 +525,10 @@ private struct SessionDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete button
|
||||
Button(role: .destructive) {
|
||||
// Delete button (from CasinoKit)
|
||||
DeleteSessionButton {
|
||||
showDeleteConfirmation = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "trash")
|
||||
Text(String(localized: "Delete Session"))
|
||||
}
|
||||
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.fill(Color.red.opacity(Design.Opacity.hint))
|
||||
)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, Design.Spacing.large)
|
||||
},
|
||||
onCancel: nil,
|
||||
onDone: { dismiss() },
|
||||
@ -865,48 +548,6 @@ private struct SessionDetailView: View {
|
||||
Text(String(localized: "This will permanently remove this session from your history."))
|
||||
}
|
||||
}
|
||||
|
||||
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: - Big Road View
|
||||
|
||||
@ -19,12 +19,14 @@ enum GamePhase: Equatable {
|
||||
}
|
||||
|
||||
/// Main game state manager.
|
||||
/// Conforms to CasinoGameState for shared game behaviors.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class GameState: SessionManagedGame {
|
||||
// MARK: - SessionManagedGame
|
||||
final class GameState: CasinoGameState {
|
||||
// MARK: - CasinoGameState Conformance
|
||||
|
||||
typealias Stats = BlackjackStats
|
||||
typealias GameSettingsType = GameSettings
|
||||
|
||||
/// Current player balance.
|
||||
var balance: Int
|
||||
|
||||
@ -69,9 +69,10 @@ enum DeckCount: Int, CaseIterable, Identifiable {
|
||||
}
|
||||
|
||||
/// Observable settings class for Blackjack configuration.
|
||||
/// Conforms to GameSettingsProtocol for shared settings behavior.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class GameSettings {
|
||||
final class GameSettings: GameSettingsProtocol {
|
||||
// MARK: - Game Style
|
||||
|
||||
/// The preset rule variation.
|
||||
|
||||
@ -399,10 +399,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"$%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" : {
|
||||
@ -1088,10 +1084,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Average bet" : {
|
||||
"comment" : "Label for the average bet value in the Statistics Sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Baccarat" : {
|
||||
"comment" : "The name of a casino game.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@ -1237,14 +1229,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Best gain" : {
|
||||
"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,
|
||||
@ -1508,10 +1492,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Biggest bet" : {
|
||||
"comment" : "Label for the \"Biggest bet\" row in the Statistics Sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"BIGGEST SWINGS" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@ -2081,10 +2061,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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" : {
|
||||
@ -2176,9 +2152,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
@ -2663,9 +2636,6 @@
|
||||
"Delete" : {
|
||||
"comment" : "A button label that deletes a session.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Delete Session" : {
|
||||
|
||||
},
|
||||
"Delete Session?" : {
|
||||
|
||||
@ -3151,14 +3121,6 @@
|
||||
"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" : {
|
||||
@ -3483,10 +3445,6 @@
|
||||
},
|
||||
"Get closer to 21 than the dealer without going over" : {
|
||||
|
||||
},
|
||||
"Global" : {
|
||||
"comment" : "Title for the \"Global\" tab in the statistics sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"H17 rule, increases house edge" : {
|
||||
"localizations" : {
|
||||
@ -3630,10 +3588,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"History" : {
|
||||
"comment" : "Title of the statistics tab that shows the user's session history.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Hit" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -4256,10 +4210,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Losing sessions" : {
|
||||
"comment" : "A label describing the number of sessions that the user lost.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Losses" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@ -4561,10 +4511,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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" : {
|
||||
@ -4632,10 +4578,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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,
|
||||
@ -4727,10 +4669,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"No Session History" : {
|
||||
"comment" : "A message displayed when a user has no session history.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No surrender option." : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -5336,10 +5274,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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" : {
|
||||
@ -6341,14 +6275,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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
|
||||
},
|
||||
"Starting balance" : {
|
||||
"comment" : "A label for the starting balance in the Balance section of a session detail view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"STARTING BALANCE" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -6879,14 +6805,6 @@
|
||||
"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
|
||||
},
|
||||
"Total gain" : {
|
||||
"comment" : "Label in the Statistics sheet for the total gain (profit or loss) from playing blackjack.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Total game time" : {
|
||||
"comment" : "Label for a stat row displaying the total game time.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -7101,10 +7019,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"win rate" : {
|
||||
"comment" : "A description of what \"win rate\" means in the context of a casino game.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Win Rate" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -7173,10 +7087,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Winning sessions" : {
|
||||
"comment" : "A label describing the number of sessions the user has won.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Wins" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@ -7227,14 +7137,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Worst loss" : {
|
||||
"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" : {
|
||||
|
||||
43
Blackjack/Blackjack/Theme/BrandingConfig+Blackjack.swift
Normal file
43
Blackjack/Blackjack/Theme/BrandingConfig+Blackjack.swift
Normal file
@ -0,0 +1,43 @@
|
||||
//
|
||||
// BrandingConfig+Blackjack.swift
|
||||
// Blackjack
|
||||
//
|
||||
// Blackjack-specific branding configurations for AppIconView and LaunchScreenView.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CasinoKit
|
||||
|
||||
extension AppIconConfig {
|
||||
/// Blackjack game icon configuration.
|
||||
static let blackjack = AppIconConfig(
|
||||
title: "BLACKJACK",
|
||||
subtitle: "21",
|
||||
iconSymbol: "suit.club.fill",
|
||||
primaryColor: Color(red: 0.05, green: 0.35, blue: 0.15),
|
||||
secondaryColor: Color(red: 0.03, green: 0.2, blue: 0.1)
|
||||
)
|
||||
}
|
||||
|
||||
extension LaunchScreenConfig {
|
||||
/// Blackjack game launch screen configuration.
|
||||
static let blackjack = LaunchScreenConfig(
|
||||
title: "BLACKJACK",
|
||||
subtitle: "21",
|
||||
tagline: "Beat the Dealer",
|
||||
iconSymbols: ["suit.club.fill", "suit.diamond.fill"],
|
||||
primaryColor: Color(red: 0.05, green: 0.35, blue: 0.15),
|
||||
secondaryColor: Color(red: 0.03, green: 0.2, blue: 0.1)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - For Development Preview Comparison
|
||||
|
||||
extension AppIconConfig {
|
||||
/// Baccarat config for side-by-side comparison in dev previews.
|
||||
static let baccarat = AppIconConfig(
|
||||
title: "BACCARAT",
|
||||
iconSymbol: "suit.spade.fill"
|
||||
)
|
||||
}
|
||||
|
||||
@ -86,6 +86,7 @@ struct GameTableView: View {
|
||||
.sheet(isPresented: $showWelcome) {
|
||||
WelcomeSheet(
|
||||
gameName: "Blackjack",
|
||||
gameEmoji: "🃏",
|
||||
features: [
|
||||
WelcomeFeature(
|
||||
icon: "target",
|
||||
|
||||
@ -19,8 +19,8 @@ struct StatisticsSheetView: View {
|
||||
SheetContainerView(
|
||||
title: String(localized: "Statistics"),
|
||||
content: {
|
||||
// Tab selector
|
||||
tabSelector
|
||||
// Tab selector (from CasinoKit)
|
||||
StatisticsTabSelector(selectedTab: $selectedTab)
|
||||
|
||||
// Content based on selected tab
|
||||
switch selectedTab {
|
||||
@ -63,34 +63,7 @@ struct StatisticsSheetView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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: - Tab Selector (uses CasinoKit.StatisticsTabSelector)
|
||||
|
||||
// MARK: - Current Session Content
|
||||
|
||||
@ -111,28 +84,10 @@ struct StatisticsSheetView: View {
|
||||
// Session stats
|
||||
sessionStatsSection(session: session)
|
||||
} else {
|
||||
noActiveSessionView
|
||||
NoActiveSessionView()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// MARK: - Global Stats Content
|
||||
|
||||
private var globalStatsContent: some View {
|
||||
@ -186,43 +141,14 @@ struct StatisticsSheetView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Session performance
|
||||
// Session performance (from CasinoKit)
|
||||
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))
|
||||
SessionPerformanceSection(
|
||||
winningSessions: stats.winningSessions,
|
||||
losingSessions: stats.losingSessions,
|
||||
bestSession: stats.bestSession,
|
||||
worstSession: stats.worstSession
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -232,7 +158,7 @@ struct StatisticsSheetView: View {
|
||||
private var sessionHistoryContent: some View {
|
||||
Group {
|
||||
if state.sessionHistory.isEmpty && state.currentSession == nil {
|
||||
emptyHistoryView
|
||||
EmptyHistoryView()
|
||||
} else {
|
||||
LazyVStack(spacing: Design.Spacing.medium) {
|
||||
// Current session at top if exists (taps go to Current tab)
|
||||
@ -293,23 +219,6 @@ struct StatisticsSheetView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@ -346,55 +255,16 @@ struct StatisticsSheetView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Chips stats section
|
||||
// Chips stats section (from CasinoKit)
|
||||
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: "Total gain"),
|
||||
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)"
|
||||
)
|
||||
}
|
||||
ChipsStatsSection(
|
||||
totalWinnings: session.totalWinnings,
|
||||
biggestWin: session.biggestWin,
|
||||
biggestLoss: session.biggestLoss,
|
||||
totalBetAmount: session.totalBetAmount,
|
||||
averageBet: session.roundsPlayed > 0 ? session.averageBet : nil,
|
||||
biggestBet: session.biggestBet
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -405,139 +275,8 @@ struct StatisticsSheetView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Statistics Tab
|
||||
|
||||
private enum StatisticsTab: CaseIterable {
|
||||
case current
|
||||
case global
|
||||
case history
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .current: return String(localized: "Current")
|
||||
case .global: return String(localized: "Global")
|
||||
case .history: return String(localized: "History")
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
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
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
Text(label)
|
||||
.font(.system(size: Design.BaseFontSize.small))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
|
||||
Text("\(count)")
|
||||
.font(.system(size: Design.BaseFontSize.large, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(Design.Opacity.light))
|
||||
.frame(width: Size.outcomeCircleSize, height: Size.outcomeCircleSize)
|
||||
|
||||
Circle()
|
||||
.stroke(color, lineWidth: Design.LineWidth.thick)
|
||||
.frame(width: Size.outcomeCircleSize, height: Size.outcomeCircleSize)
|
||||
|
||||
Circle()
|
||||
.fill(color.opacity(Design.Opacity.medium))
|
||||
.frame(width: Size.outcomeCircleInner, height: Size.outcomeCircleInner)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatRow: View {
|
||||
let icon: String
|
||||
let label: String
|
||||
let value: String
|
||||
var valueColor: Color = .white
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: Design.BaseFontSize.large))
|
||||
.foregroundStyle(Color.Sheet.accent)
|
||||
.frame(width: Size.statIconWidth)
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: Design.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: Design.BaseFontSize.body, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(valueColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChipStatRow: View {
|
||||
let icon: String
|
||||
let iconColor: Color
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(iconColor)
|
||||
.frame(width: Size.chipIconSize, height: Size.chipIconSize)
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: Design.BaseFontSize.small, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: Design.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: Design.BaseFontSize.body, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(Color.Sheet.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
// MARK: - Statistics Tab & Supporting Views
|
||||
// StatisticsTab, StatColumn, OutcomeCircle, StatRow, ChipStatRow are now provided by CasinoKit
|
||||
|
||||
// MARK: - Session Detail View
|
||||
|
||||
@ -553,8 +292,13 @@ private struct SessionDetailView: View {
|
||||
SheetContainerView(
|
||||
title: styleDisplayName,
|
||||
content: {
|
||||
// Session header info
|
||||
sessionHeader
|
||||
// Session header info (from CasinoKit)
|
||||
SessionDetailHeader(
|
||||
startTime: session.startTime,
|
||||
endReason: session.endReason,
|
||||
netResult: session.netResult,
|
||||
winRate: session.winRate
|
||||
)
|
||||
|
||||
// Game stats section
|
||||
SheetSection(title: String(localized: "GAME STATS"), icon: "gamecontroller.fill") {
|
||||
@ -569,7 +313,7 @@ private struct SessionDetailView: View {
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
// Win/Loss/Push
|
||||
// Win/Loss/Push (from CasinoKit)
|
||||
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)
|
||||
@ -578,7 +322,7 @@ private struct SessionDetailView: View {
|
||||
|
||||
Divider().background(Color.white.opacity(Design.Opacity.hint))
|
||||
|
||||
// Game time
|
||||
// Game time (from CasinoKit)
|
||||
StatRow(icon: "clock", label: String(localized: "Total game time"), value: SessionFormatter.formatDuration(session.duration))
|
||||
|
||||
// Blackjack-specific stats
|
||||
@ -588,97 +332,31 @@ private struct SessionDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Chips stats section
|
||||
// Chips stats section (from CasinoKit)
|
||||
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))
|
||||
}
|
||||
|
||||
// Delete button
|
||||
Button(role: .destructive) {
|
||||
showDeleteConfirmation = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "trash")
|
||||
Text(String(localized: "Delete Session"))
|
||||
}
|
||||
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.fill(Color.red.opacity(Design.Opacity.hint))
|
||||
ChipsStatsSection(
|
||||
totalWinnings: session.totalWinnings,
|
||||
biggestWin: session.biggestWin,
|
||||
biggestLoss: session.biggestLoss,
|
||||
totalBetAmount: session.totalBetAmount,
|
||||
averageBet: session.roundsPlayed > 0 ? session.averageBet : nil,
|
||||
biggestBet: session.biggestBet
|
||||
)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, Design.Spacing.large)
|
||||
|
||||
// Balance section (from CasinoKit)
|
||||
SheetSection(title: String(localized: "BALANCE"), icon: "banknote.fill") {
|
||||
BalanceSection(
|
||||
startingBalance: session.startingBalance,
|
||||
endingBalance: session.endingBalance,
|
||||
netResult: session.netResult
|
||||
)
|
||||
}
|
||||
|
||||
// Delete button (from CasinoKit)
|
||||
DeleteSessionButton {
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
},
|
||||
onCancel: nil,
|
||||
onDone: { dismiss() },
|
||||
@ -698,57 +376,6 @@ private struct SessionDetailView: View {
|
||||
Text(String(localized: "This will permanently remove this session from your history."))
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
static let outcomeCircleSize: CGFloat = 48
|
||||
static let outcomeCircleInner: CGFloat = 24
|
||||
static let statIconWidth: CGFloat = 32
|
||||
static let chipIconSize: CGFloat = 28
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
@ -15,6 +15,8 @@
|
||||
// - TableLimits
|
||||
// - OnboardingState
|
||||
// - TooltipManager, TooltipConfig
|
||||
// - GameSettingsProtocol (shared settings interface)
|
||||
// - SettingsKeys, SettingsDefaults (persistence helpers)
|
||||
|
||||
// MARK: - Views
|
||||
// - CardView, CardFrontView, CardBackView, CardPlaceholderView
|
||||
@ -71,6 +73,7 @@
|
||||
// - SelectionIndicator (checkmark circle)
|
||||
// - BadgePill (capsule badge for values)
|
||||
|
||||
|
||||
// MARK: - Branding
|
||||
// - AppIconView, AppIconConfig
|
||||
// - LaunchScreenView, LaunchScreenConfig, StaticLaunchScreenView
|
||||
@ -95,6 +98,7 @@
|
||||
// - GameSession<Stats> (generic session with game-specific stats)
|
||||
// - GameSpecificStats (protocol for game-specific statistics)
|
||||
// - SessionManagedGame (protocol for games with session management)
|
||||
// - CasinoGameState (protocol extending SessionManagedGame with shared behaviors)
|
||||
// - SessionEndReason (.manualEnd, .brokeOut)
|
||||
// - RoundOutcome (.win, .lose, .push)
|
||||
// - AggregatedSessionStats (combined stats from multiple sessions)
|
||||
@ -104,6 +108,21 @@
|
||||
// - CurrentSessionHeader, SessionSummaryRow (UI components)
|
||||
// - GameStatRow (display a stat item)
|
||||
|
||||
// MARK: - Statistics Components
|
||||
// - StatisticsTab (enum: current, global, history)
|
||||
// - StatisticsTabSelector (tab picker for stats views)
|
||||
// - StatColumn (vertical stat display)
|
||||
// - OutcomeCircle (win/loss/push circles)
|
||||
// - StatRow (horizontal stat with icon)
|
||||
// - ChipStatRow (stat with colored circle icon)
|
||||
// - NoActiveSessionView (placeholder)
|
||||
// - EmptyHistoryView (placeholder)
|
||||
// - SessionPerformanceSection (winning/losing sessions)
|
||||
// - ChipsStatsSection (betting stats)
|
||||
// - BalanceSection (starting/ending balance)
|
||||
// - SessionDetailHeader (date, result, win rate)
|
||||
// - DeleteSessionButton (destructive delete button)
|
||||
|
||||
// MARK: - Debug
|
||||
// - debugBorder(_:color:label:) View modifier
|
||||
|
||||
|
||||
@ -70,26 +70,8 @@ public enum Rank: Int, CaseIterable, Identifiable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The baccarat point value (Ace = 1, 2-9 = face value, 10/J/Q/K = 0).
|
||||
public var baccaratValue: Int {
|
||||
switch self {
|
||||
case .ace: return 1
|
||||
case .two, .three, .four, .five, .six, .seven, .eight, .nine:
|
||||
return rawValue
|
||||
case .ten, .jack, .queen, .king:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/// The blackjack value (Ace = 1 or 11, 2-10 = face value, J/Q/K = 10).
|
||||
/// Note: Ace flexibility (1 or 11) should be handled by game logic.
|
||||
public var blackjackValue: Int {
|
||||
switch self {
|
||||
case .ace: return 11 // Game logic should handle soft/hard hands
|
||||
case .jack, .queen, .king: return 10
|
||||
default: return rawValue
|
||||
}
|
||||
}
|
||||
// Game-specific value properties (baccaratValue, blackjackValue) should be
|
||||
// defined as extensions in the respective game apps.
|
||||
|
||||
/// Accessibility name for VoiceOver.
|
||||
public var accessibilityName: String {
|
||||
@ -123,15 +105,7 @@ public struct Card: Identifiable, Equatable, Sendable {
|
||||
self.rank = rank
|
||||
}
|
||||
|
||||
/// The baccarat point value of this card.
|
||||
public var baccaratValue: Int {
|
||||
rank.baccaratValue
|
||||
}
|
||||
|
||||
/// The blackjack value of this card.
|
||||
public var blackjackValue: Int {
|
||||
rank.blackjackValue
|
||||
}
|
||||
// Game-specific value properties should be defined as extensions in game apps.
|
||||
|
||||
/// Display string showing rank and suit together.
|
||||
public var display: String {
|
||||
|
||||
128
CasinoKit/Sources/CasinoKit/Models/CasinoGameState.swift
Normal file
128
CasinoKit/Sources/CasinoKit/Models/CasinoGameState.swift
Normal file
@ -0,0 +1,128 @@
|
||||
//
|
||||
// CasinoGameState.swift
|
||||
// CasinoKit
|
||||
//
|
||||
// Protocol defining common state management patterns for casino games.
|
||||
// Extends SessionManagedGame with additional shared functionality.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Protocol for casino game state classes that manage game flow,
|
||||
/// session tracking, and common behaviors.
|
||||
///
|
||||
/// This extends `SessionManagedGame` to add additional requirements
|
||||
/// that are common across all casino games in this app.
|
||||
@MainActor
|
||||
public protocol CasinoGameState: SessionManagedGame {
|
||||
/// The settings object conforming to GameSettingsProtocol.
|
||||
associatedtype GameSettingsType: GameSettingsProtocol
|
||||
|
||||
/// Game-specific settings.
|
||||
var settings: GameSettingsType { get }
|
||||
|
||||
/// The shared sound manager.
|
||||
var soundManager: SoundManager { get }
|
||||
|
||||
/// Onboarding state for first-time users.
|
||||
var onboarding: OnboardingState { get }
|
||||
|
||||
/// Resets the game to starting conditions (balance, cards, etc.)
|
||||
/// without clearing session history.
|
||||
func resetGame()
|
||||
|
||||
/// Clears all data including session history.
|
||||
func clearAllData()
|
||||
|
||||
/// Aggregated statistics from all sessions.
|
||||
var aggregatedStats: AggregatedSessionStats { get }
|
||||
}
|
||||
|
||||
// MARK: - Default Implementations
|
||||
|
||||
public extension CasinoGameState {
|
||||
/// Default implementation uses shared SoundManager.
|
||||
var soundManager: SoundManager { SoundManager.shared }
|
||||
|
||||
/// Default aggregated stats computed from session history.
|
||||
var aggregatedStats: AggregatedSessionStats {
|
||||
sessionHistory.aggregatedStats()
|
||||
}
|
||||
|
||||
/// Play a sound effect if enabled.
|
||||
func playSound(_ sound: GameSound) {
|
||||
soundManager.play(sound)
|
||||
}
|
||||
|
||||
/// Play light haptic feedback if enabled.
|
||||
func hapticLight() {
|
||||
soundManager.hapticLight()
|
||||
}
|
||||
|
||||
/// Play medium haptic feedback if enabled.
|
||||
func hapticMedium() {
|
||||
soundManager.hapticMedium()
|
||||
}
|
||||
|
||||
/// Play success haptic feedback if enabled.
|
||||
func hapticSuccess() {
|
||||
soundManager.hapticSuccess()
|
||||
}
|
||||
|
||||
/// Play error haptic feedback if enabled.
|
||||
func hapticError() {
|
||||
soundManager.hapticError()
|
||||
}
|
||||
|
||||
/// Convenience: Check if we should play sounds.
|
||||
var isSoundEnabled: Bool {
|
||||
settings.soundEnabled && soundManager.soundEnabled
|
||||
}
|
||||
|
||||
/// Convenience: Check if we should play haptics.
|
||||
var isHapticsEnabled: Bool {
|
||||
settings.hapticsEnabled && soundManager.hapticsEnabled
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Balance Management Extensions
|
||||
|
||||
public extension CasinoGameState {
|
||||
/// Whether the player is out of chips (balance is 0 or below minimum bet).
|
||||
var isBroke: Bool {
|
||||
balance < settings.minBet
|
||||
}
|
||||
|
||||
/// Whether the player can afford a bet of the given amount.
|
||||
func canAffordBet(_ amount: Int) -> Bool {
|
||||
balance >= amount
|
||||
}
|
||||
|
||||
/// The maximum bet the player can currently place.
|
||||
var maxAffordableBet: Int {
|
||||
min(balance, settings.maxBet)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Game Flow Extensions
|
||||
|
||||
public extension CasinoGameState {
|
||||
/// Ends the current session due to running out of chips.
|
||||
func endSessionDueToBroke() {
|
||||
if currentSession != nil {
|
||||
endCurrentSession(reason: .brokeOut)
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if the player is broke and handles session ending if needed.
|
||||
/// Returns true if the player is broke.
|
||||
@discardableResult
|
||||
func checkBrokeStatus() -> Bool {
|
||||
if isBroke {
|
||||
endSessionDueToBroke()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
106
CasinoKit/Sources/CasinoKit/Models/GameSettingsProtocol.swift
Normal file
106
CasinoKit/Sources/CasinoKit/Models/GameSettingsProtocol.swift
Normal file
@ -0,0 +1,106 @@
|
||||
//
|
||||
// GameSettingsProtocol.swift
|
||||
// CasinoKit
|
||||
//
|
||||
// Protocol defining common settings shared across all casino games.
|
||||
// Each game can conform to this protocol and add game-specific settings.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Protocol defining the minimum required settings for any casino game.
|
||||
/// Games conforming to this protocol share common UI settings and behaviors.
|
||||
@MainActor
|
||||
public protocol GameSettingsProtocol: AnyObject {
|
||||
// MARK: - Table Configuration
|
||||
|
||||
/// The table limits preset (affects min/max bets).
|
||||
var tableLimits: TableLimits { get set }
|
||||
|
||||
/// Minimum bet amount (derived from tableLimits).
|
||||
var minBet: Int { get }
|
||||
|
||||
/// Maximum bet amount (derived from tableLimits).
|
||||
var maxBet: Int { get }
|
||||
|
||||
/// Starting balance for new sessions.
|
||||
var startingBalance: Int { get set }
|
||||
|
||||
// MARK: - Animation Settings
|
||||
|
||||
/// Whether to show card dealing and other animations.
|
||||
var showAnimations: Bool { get set }
|
||||
|
||||
/// Speed multiplier for dealing (1.0 = normal).
|
||||
var dealingSpeed: Double { get set }
|
||||
|
||||
// MARK: - Display Settings
|
||||
|
||||
/// Whether to show hints and recommendations.
|
||||
var showHints: Bool { get set }
|
||||
|
||||
// MARK: - Sound Settings
|
||||
|
||||
/// Whether sound effects are enabled.
|
||||
var soundEnabled: Bool { get set }
|
||||
|
||||
/// Whether haptic feedback is enabled.
|
||||
var hapticsEnabled: Bool { get set }
|
||||
|
||||
/// Volume level for sound effects (0.0 to 1.0).
|
||||
var soundVolume: Float { get set }
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
/// Saves settings to persistent storage.
|
||||
func save()
|
||||
|
||||
/// Loads settings from persistent storage.
|
||||
func load()
|
||||
|
||||
/// Resets all settings to defaults.
|
||||
func resetToDefaults()
|
||||
}
|
||||
|
||||
// MARK: - Default Implementations
|
||||
|
||||
public extension GameSettingsProtocol {
|
||||
/// Minimum bet derived from table limits.
|
||||
var minBet: Int {
|
||||
tableLimits.minBet
|
||||
}
|
||||
|
||||
/// Maximum bet derived from table limits.
|
||||
var maxBet: Int {
|
||||
tableLimits.maxBet
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings Persistence Helpers
|
||||
|
||||
/// Common settings keys for persistence.
|
||||
public enum SettingsKeys {
|
||||
public static let tableLimits = "settings.tableLimits"
|
||||
public static let startingBalance = "settings.startingBalance"
|
||||
public static let showAnimations = "settings.showAnimations"
|
||||
public static let dealingSpeed = "settings.dealingSpeed"
|
||||
public static let showHints = "settings.showHints"
|
||||
public static let soundEnabled = "settings.soundEnabled"
|
||||
public static let hapticsEnabled = "settings.hapticsEnabled"
|
||||
public static let soundVolume = "settings.soundVolume"
|
||||
}
|
||||
|
||||
// MARK: - Settings Defaults
|
||||
|
||||
/// Default values for common settings.
|
||||
public enum SettingsDefaults {
|
||||
public static let tableLimits: TableLimits = .casual
|
||||
public static let startingBalance: Int = 1_000
|
||||
public static let showAnimations: Bool = true
|
||||
public static let dealingSpeed: Double = 1.0
|
||||
public static let showHints: Bool = true
|
||||
public static let soundEnabled: Bool = true
|
||||
public static let hapticsEnabled: Bool = true
|
||||
public static let soundVolume: Float = 1.0
|
||||
}
|
||||
|
||||
@ -217,6 +217,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"$%lld" : {
|
||||
"comment" : "A label displaying the starting balance for a session. The value inside the parentheses is replaced with the actual starting balance.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"$%lld bet" : {
|
||||
"comment" : "A value describing the bet amount in the accessibility label. The argument is the bet amount.",
|
||||
"extractionState" : "stale",
|
||||
@ -431,6 +435,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Average bet" : {
|
||||
"comment" : "Label for the average bet value in the ChipsStatsSection.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Balance" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -453,6 +461,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Best gain" : {
|
||||
"comment" : "Label in the \"Chips Stats Section\" view for the user's best gain.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Best session" : {
|
||||
"comment" : "A label describing the best session amount in the statistics view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Betting disabled" : {
|
||||
"comment" : "A hint that appears when a betting zone is disabled.",
|
||||
"extractionState" : "stale",
|
||||
@ -482,6 +498,10 @@
|
||||
"comment" : "The accessibility label for the betting hint view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Biggest bet" : {
|
||||
"comment" : "Label for the \"Biggest bet\" statistic in the ChipsStatsSection.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Bust" : {
|
||||
"comment" : "A string describing when a player busts out of a game.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -649,6 +669,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Completed sessions will appear here." : {
|
||||
"comment" : "A description of what to expect to see in the completed sessions section of the statistics view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Contact Us" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -671,6 +695,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Current" : {
|
||||
"comment" : "Title of the \"Current\" tab in the statistics view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Current Session" : {
|
||||
"comment" : "A label for the header of the current session section.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -748,6 +776,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Delete Session" : {
|
||||
"comment" : "A button label that deletes a session.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Diamonds" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -899,6 +931,14 @@
|
||||
"comment" : "A label indicating that a session has ended.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Ended manually" : {
|
||||
"comment" : "A description of how a session ended manually.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Ending balance" : {
|
||||
"comment" : "A label describing the user's balance at the end of a session.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Exclusive VIP room" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -1059,6 +1099,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Global" : {
|
||||
"comment" : "Title for the \"Global\" tab in the statistics view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Got it" : {
|
||||
|
||||
},
|
||||
@ -1132,6 +1176,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"History" : {
|
||||
"comment" : "Title of the History tab in the Statistics sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"iCloud Sync" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -1374,6 +1422,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Losing sessions" : {
|
||||
"comment" : "A label for the number of sessions a user has lost.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Low Stakes" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -1495,6 +1547,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"No Active Session" : {
|
||||
"comment" : "A label describing a state where there is no active session.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No bet" : {
|
||||
"comment" : "A description of a zone with no active bet.",
|
||||
"extractionState" : "stale",
|
||||
@ -1519,6 +1575,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"No Session History" : {
|
||||
|
||||
},
|
||||
"Our apps do not integrate with third-party services that collect user data. We do not share any information with third parties." : {
|
||||
"localizations" : {
|
||||
@ -1674,6 +1733,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Ran out of chips" : {
|
||||
"comment" : "A description of why a casino session might have ended with a negative net result.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Regular casino table" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -1924,6 +1987,14 @@
|
||||
},
|
||||
"Start Playing" : {
|
||||
|
||||
},
|
||||
"Start playing to begin tracking your session." : {
|
||||
"comment" : "A description below the title of the view, explaining that users can start playing to track their sessions.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Starting balance" : {
|
||||
"comment" : "A label describing the starting balance of a session.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Statistics" : {
|
||||
"localizations" : {
|
||||
@ -2039,6 +2110,14 @@
|
||||
"comment" : "A label displayed alongside the total winnings in the result banner.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Total bet" : {
|
||||
"comment" : "Label for the total amount bet in a statistics view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Total gain" : {
|
||||
"comment" : "Label for the \"Total gain\" row in the chips statistics section.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Total loss: %lld" : {
|
||||
"comment" : "A string that describes the total loss in a casino game. The argument is the absolute value of the total loss, formatted in U.S. Dollars.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -2249,6 +2328,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"win rate" : {
|
||||
"comment" : "A label describing the win rate of a session.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Winning sessions" : {
|
||||
"comment" : "A label describing the number of sessions the user has won.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Worst loss" : {
|
||||
"comment" : "Label for the worst loss in the statistics section.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Worst session" : {
|
||||
"comment" : "A label for the worst session amount in the statistics view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"You can disable iCloud sync at any time in the app settings" : {
|
||||
"comment" : "Text in the Privacy Policy View explaining how to disable iCloud sync in the app settings.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
|
||||
@ -33,31 +33,17 @@ public struct AppIconConfig: Sendable {
|
||||
self.accentColor = accentColor
|
||||
}
|
||||
|
||||
// MARK: - Preset Configurations
|
||||
// MARK: - Example Preset Configurations
|
||||
// Game-specific presets should be defined in the respective apps as extensions.
|
||||
|
||||
/// Baccarat game icon configuration.
|
||||
public static let baccarat = AppIconConfig(
|
||||
title: "BACCARAT",
|
||||
iconSymbol: "suit.spade.fill"
|
||||
)
|
||||
|
||||
/// Blackjack game icon configuration.
|
||||
public static let blackjack = AppIconConfig(
|
||||
title: "BLACKJACK",
|
||||
subtitle: "21",
|
||||
iconSymbol: "suit.club.fill",
|
||||
primaryColor: Color(red: 0.05, green: 0.35, blue: 0.15),
|
||||
secondaryColor: Color(red: 0.03, green: 0.2, blue: 0.1)
|
||||
)
|
||||
|
||||
/// Poker game icon configuration.
|
||||
/// Poker game icon configuration (example preset).
|
||||
public static let poker = AppIconConfig(
|
||||
title: "POKER",
|
||||
iconSymbol: "suit.diamond.fill",
|
||||
accentColor: .red
|
||||
)
|
||||
|
||||
/// Roulette game icon configuration.
|
||||
/// Roulette game icon configuration (example preset).
|
||||
public static let roulette = AppIconConfig(
|
||||
title: "ROULETTE",
|
||||
iconSymbol: "circle.grid.3x3.fill",
|
||||
@ -195,22 +181,20 @@ private struct DiamondPatternOverlay: View {
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview("Baccarat Icon") {
|
||||
AppIconView(config: .baccarat, size: 512)
|
||||
#Preview("Poker Icon") {
|
||||
AppIconView(config: .poker, size: 512)
|
||||
.padding()
|
||||
.background(Color.gray)
|
||||
}
|
||||
|
||||
#Preview("Blackjack Icon") {
|
||||
AppIconView(config: .blackjack, size: 512)
|
||||
#Preview("Roulette Icon") {
|
||||
AppIconView(config: .roulette, size: 512)
|
||||
.padding()
|
||||
.background(Color.gray)
|
||||
}
|
||||
|
||||
#Preview("All Icons") {
|
||||
HStack(spacing: 20) {
|
||||
AppIconView(config: .baccarat, size: 200)
|
||||
AppIconView(config: .blackjack, size: 200)
|
||||
AppIconView(config: .poker, size: 200)
|
||||
AppIconView(config: .roulette, size: 200)
|
||||
}
|
||||
|
||||
@ -134,6 +134,6 @@ public struct IconExportView: View {
|
||||
}
|
||||
|
||||
#Preview("Icon Export") {
|
||||
IconExportView(config: .baccarat)
|
||||
IconExportView(config: .poker)
|
||||
}
|
||||
|
||||
|
||||
@ -38,26 +38,10 @@ public struct LaunchScreenConfig: Sendable {
|
||||
self.showLoadingIndicator = showLoadingIndicator
|
||||
}
|
||||
|
||||
// MARK: - Preset Configurations
|
||||
// MARK: - Example Preset Configurations
|
||||
// Game-specific presets should be defined in the respective apps as extensions.
|
||||
|
||||
/// Baccarat game launch screen configuration.
|
||||
public static let baccarat = LaunchScreenConfig(
|
||||
title: "BACCARAT",
|
||||
tagline: "The Classic Casino Card Game",
|
||||
iconSymbols: ["suit.spade.fill", "suit.heart.fill"]
|
||||
)
|
||||
|
||||
/// Blackjack game launch screen configuration.
|
||||
public static let blackjack = LaunchScreenConfig(
|
||||
title: "BLACKJACK",
|
||||
subtitle: "21",
|
||||
tagline: "Beat the Dealer",
|
||||
iconSymbols: ["suit.club.fill", "suit.diamond.fill"],
|
||||
primaryColor: Color(red: 0.05, green: 0.35, blue: 0.15),
|
||||
secondaryColor: Color(red: 0.03, green: 0.2, blue: 0.1)
|
||||
)
|
||||
|
||||
/// Poker game launch screen configuration.
|
||||
/// Poker game launch screen configuration (example preset).
|
||||
public static let poker = LaunchScreenConfig(
|
||||
title: "POKER",
|
||||
tagline: "Texas Hold'em",
|
||||
@ -339,15 +323,11 @@ public struct StaticLaunchScreenView: View {
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview("Baccarat Launch") {
|
||||
LaunchScreenView(config: .baccarat)
|
||||
}
|
||||
|
||||
#Preview("Blackjack Launch") {
|
||||
LaunchScreenView(config: .blackjack)
|
||||
#Preview("Poker Launch") {
|
||||
LaunchScreenView(config: .poker)
|
||||
}
|
||||
|
||||
#Preview("Static Launch") {
|
||||
StaticLaunchScreenView(config: .baccarat)
|
||||
StaticLaunchScreenView(config: .poker)
|
||||
}
|
||||
|
||||
|
||||
@ -142,7 +142,7 @@ public enum ActionButtonStyle {
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("BlackJack Action Buttons") {
|
||||
#Preview("Casino Action Buttons") {
|
||||
ZStack {
|
||||
Color.CasinoTable.felt.ignoresSafeArea()
|
||||
|
||||
|
||||
@ -76,14 +76,14 @@ public struct GameOverView: View {
|
||||
|
||||
// Stats card
|
||||
VStack(spacing: CasinoDesign.Spacing.medium) {
|
||||
StatRow(
|
||||
GameOverStatRow(
|
||||
label: String(localized: "Rounds Played", bundle: .module),
|
||||
value: "\(roundsPlayed)",
|
||||
fontSize: statsFontSize
|
||||
)
|
||||
|
||||
ForEach(additionalStats.indices, id: \.self) { index in
|
||||
StatRow(
|
||||
GameOverStatRow(
|
||||
label: additionalStats[index].0,
|
||||
value: additionalStats[index].1,
|
||||
fontSize: statsFontSize
|
||||
@ -170,7 +170,7 @@ public struct GameOverView: View {
|
||||
}
|
||||
|
||||
/// A single stat row for the game over view.
|
||||
private struct StatRow: View {
|
||||
private struct GameOverStatRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
let fontSize: CGFloat
|
||||
|
||||
@ -332,25 +332,8 @@ public struct CurrentSessionHeader: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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: - Stat Column
|
||||
// StatColumn is now provided by StatisticsComponents.swift
|
||||
|
||||
// MARK: - Game Stats Display Row
|
||||
|
||||
|
||||
@ -0,0 +1,581 @@
|
||||
//
|
||||
// StatisticsComponents.swift
|
||||
// CasinoKit
|
||||
//
|
||||
// Reusable UI components for statistics display.
|
||||
// Used in StatisticsSheetView across all casino games.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Statistics Tab
|
||||
|
||||
/// Tab options for statistics views.
|
||||
public enum StatisticsTab: CaseIterable, Sendable {
|
||||
case current
|
||||
case global
|
||||
case history
|
||||
|
||||
public var title: String {
|
||||
switch self {
|
||||
case .current: return String(localized: "Current", bundle: .module)
|
||||
case .global: return String(localized: "Global", bundle: .module)
|
||||
case .history: return String(localized: "History", bundle: .module)
|
||||
}
|
||||
}
|
||||
|
||||
public var icon: String {
|
||||
switch self {
|
||||
case .current: return "play.circle.fill"
|
||||
case .global: return "globe"
|
||||
case .history: return "clock.arrow.circlepath"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Statistics Tab Selector
|
||||
|
||||
/// A tab selector for switching between Current, Global, and History tabs.
|
||||
public struct StatisticsTabSelector: View {
|
||||
@Binding public var selectedTab: StatisticsTab
|
||||
|
||||
public init(selectedTab: Binding<StatisticsTab>) {
|
||||
self._selectedTab = selectedTab
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(StatisticsTab.allCases, id: \.self) { tab in
|
||||
Button {
|
||||
withAnimation(.spring(duration: CasinoDesign.Animation.springDuration)) {
|
||||
selectedTab = tab
|
||||
}
|
||||
} label: {
|
||||
VStack(spacing: CasinoDesign.Spacing.xSmall) {
|
||||
Image(systemName: tab.icon)
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.large))
|
||||
Text(tab.title)
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.small, weight: .medium))
|
||||
}
|
||||
.foregroundStyle(selectedTab == tab ? Color.Sheet.accent : .white.opacity(CasinoDesign.Opacity.medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, CasinoDesign.Spacing.medium)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium)
|
||||
.fill(selectedTab == tab ? Color.Sheet.accent.opacity(CasinoDesign.Opacity.hint) : .clear)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stat Column
|
||||
|
||||
/// A vertical column displaying a value and label, used in summary sections.
|
||||
public struct StatColumn: View {
|
||||
public let value: String
|
||||
public let label: String
|
||||
public var valueColor: Color
|
||||
|
||||
public init(value: String, label: String, valueColor: Color = .white) {
|
||||
self.value = value
|
||||
self.label = label
|
||||
self.valueColor = valueColor
|
||||
}
|
||||
|
||||
public 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: - Outcome Circle
|
||||
|
||||
/// A circular indicator showing win/loss/push counts with a colored ring.
|
||||
public struct OutcomeCircle: View {
|
||||
public let label: String
|
||||
public let count: Int
|
||||
public let color: Color
|
||||
|
||||
// Local size constants
|
||||
private let circleSize: CGFloat = 48
|
||||
private let innerSize: CGFloat = 24
|
||||
|
||||
public init(label: String, count: Int, color: Color) {
|
||||
self.label = label
|
||||
self.count = count
|
||||
self.color = color
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: CasinoDesign.Spacing.small) {
|
||||
Text(label)
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.small))
|
||||
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
|
||||
|
||||
Text("\(count)")
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.large, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(CasinoDesign.Opacity.light))
|
||||
.frame(width: circleSize, height: circleSize)
|
||||
|
||||
Circle()
|
||||
.stroke(color, lineWidth: CasinoDesign.LineWidth.thick)
|
||||
.frame(width: circleSize, height: circleSize)
|
||||
|
||||
Circle()
|
||||
.fill(color.opacity(CasinoDesign.Opacity.medium))
|
||||
.frame(width: innerSize, height: innerSize)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stat Row
|
||||
|
||||
/// A horizontal row with icon, label, and value.
|
||||
public struct StatRow: View {
|
||||
public let icon: String
|
||||
public let label: String
|
||||
public let value: String
|
||||
public var valueColor: Color
|
||||
|
||||
// Local size constants
|
||||
private let iconWidth: CGFloat = 32
|
||||
|
||||
public init(icon: String, label: String, value: String, valueColor: Color = .white) {
|
||||
self.icon = icon
|
||||
self.label = label
|
||||
self.value = value
|
||||
self.valueColor = valueColor
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.large))
|
||||
.foregroundStyle(Color.Sheet.accent)
|
||||
.frame(width: iconWidth)
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.strong))
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.body, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(valueColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chip Stat Row
|
||||
|
||||
/// A row displaying a stat with a colored circular icon, commonly used for chip/money stats.
|
||||
public struct ChipStatRow: View {
|
||||
public let icon: String
|
||||
public let iconColor: Color
|
||||
public let label: String
|
||||
public let value: String
|
||||
|
||||
// Local size constants
|
||||
private let chipIconSize: CGFloat = 28
|
||||
|
||||
public init(icon: String, iconColor: Color, label: String, value: String) {
|
||||
self.icon = icon
|
||||
self.iconColor = iconColor
|
||||
self.label = label
|
||||
self.value = value
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(iconColor)
|
||||
.frame(width: chipIconSize, height: chipIconSize)
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.small, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.strong))
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.body, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(Color.Sheet.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - No Active Session View
|
||||
|
||||
/// Placeholder view shown when there's no active session.
|
||||
public struct NoActiveSessionView: View {
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: CasinoDesign.Spacing.large) {
|
||||
Image(systemName: "play.slash")
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.xxLarge * 2))
|
||||
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.light))
|
||||
|
||||
Text(String(localized: "No Active Session", bundle: .module))
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.large, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
|
||||
|
||||
Text(String(localized: "Start playing to begin tracking your session.", bundle: .module))
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.light))
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(CasinoDesign.Spacing.xxLarge)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty History View
|
||||
|
||||
/// Placeholder view shown when session history is empty.
|
||||
public struct EmptyHistoryView: View {
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: CasinoDesign.Spacing.large) {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.xxLarge * 2))
|
||||
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.light))
|
||||
|
||||
Text(String(localized: "No Session History", bundle: .module))
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.large, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
|
||||
|
||||
Text(String(localized: "Completed sessions will appear here.", bundle: .module))
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.light))
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(CasinoDesign.Spacing.xxLarge)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session Performance Section
|
||||
|
||||
/// A reusable section showing session win/loss performance stats.
|
||||
public struct SessionPerformanceSection: View {
|
||||
public let winningSessions: Int
|
||||
public let losingSessions: Int
|
||||
public let bestSession: Int
|
||||
public let worstSession: Int
|
||||
|
||||
public init(
|
||||
winningSessions: Int,
|
||||
losingSessions: Int,
|
||||
bestSession: Int,
|
||||
worstSession: Int
|
||||
) {
|
||||
self.winningSessions = winningSessions
|
||||
self.losingSessions = losingSessions
|
||||
self.bestSession = bestSession
|
||||
self.worstSession = worstSession
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: CasinoDesign.Spacing.medium) {
|
||||
HStack {
|
||||
Text(String(localized: "Winning sessions", bundle: .module))
|
||||
Spacer()
|
||||
Text("\(winningSessions)")
|
||||
.foregroundStyle(.green)
|
||||
.bold()
|
||||
}
|
||||
HStack {
|
||||
Text(String(localized: "Losing sessions", bundle: .module))
|
||||
Spacer()
|
||||
Text("\(losingSessions)")
|
||||
.foregroundStyle(.red)
|
||||
.bold()
|
||||
}
|
||||
|
||||
Divider().background(Color.white.opacity(CasinoDesign.Opacity.hint))
|
||||
|
||||
HStack {
|
||||
Text(String(localized: "Best session", bundle: .module))
|
||||
Spacer()
|
||||
Text(SessionFormatter.formatMoney(bestSession))
|
||||
.foregroundStyle(.green)
|
||||
.bold()
|
||||
}
|
||||
HStack {
|
||||
Text(String(localized: "Worst session", bundle: .module))
|
||||
Spacer()
|
||||
Text(SessionFormatter.formatMoney(worstSession))
|
||||
.foregroundStyle(.red)
|
||||
.bold()
|
||||
}
|
||||
}
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.strong))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chips Stats Section
|
||||
|
||||
/// A reusable section showing chip/betting statistics.
|
||||
public struct ChipsStatsSection: View {
|
||||
public let totalWinnings: Int
|
||||
public let biggestWin: Int
|
||||
public let biggestLoss: Int
|
||||
public let totalBetAmount: Int
|
||||
public let averageBet: Int?
|
||||
public let biggestBet: Int
|
||||
|
||||
public init(
|
||||
totalWinnings: Int,
|
||||
biggestWin: Int,
|
||||
biggestLoss: Int,
|
||||
totalBetAmount: Int,
|
||||
averageBet: Int?,
|
||||
biggestBet: Int
|
||||
) {
|
||||
self.totalWinnings = totalWinnings
|
||||
self.biggestWin = biggestWin
|
||||
self.biggestLoss = biggestLoss
|
||||
self.totalBetAmount = totalBetAmount
|
||||
self.averageBet = averageBet
|
||||
self.biggestBet = biggestBet
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: CasinoDesign.Spacing.medium) {
|
||||
ChipStatRow(
|
||||
icon: "chart.line.uptrend.xyaxis",
|
||||
iconColor: totalWinnings >= 0 ? .green : .red,
|
||||
label: String(localized: "Total gain", bundle: .module),
|
||||
value: SessionFormatter.formatMoney(totalWinnings)
|
||||
)
|
||||
|
||||
ChipStatRow(
|
||||
icon: "arrow.up.circle.fill",
|
||||
iconColor: .green,
|
||||
label: String(localized: "Best gain", bundle: .module),
|
||||
value: SessionFormatter.formatMoney(biggestWin)
|
||||
)
|
||||
|
||||
ChipStatRow(
|
||||
icon: "arrow.down.circle.fill",
|
||||
iconColor: .red,
|
||||
label: String(localized: "Worst loss", bundle: .module),
|
||||
value: SessionFormatter.formatMoney(biggestLoss)
|
||||
)
|
||||
|
||||
Divider().background(Color.white.opacity(CasinoDesign.Opacity.hint))
|
||||
|
||||
ChipStatRow(
|
||||
icon: "plusminus.circle.fill",
|
||||
iconColor: .blue,
|
||||
label: String(localized: "Total bet", bundle: .module),
|
||||
value: "$\(totalBetAmount)"
|
||||
)
|
||||
|
||||
if let avg = averageBet {
|
||||
ChipStatRow(
|
||||
icon: "equal.circle.fill",
|
||||
iconColor: .purple,
|
||||
label: String(localized: "Average bet", bundle: .module),
|
||||
value: "$\(avg)"
|
||||
)
|
||||
}
|
||||
|
||||
ChipStatRow(
|
||||
icon: "star.circle.fill",
|
||||
iconColor: .orange,
|
||||
label: String(localized: "Biggest bet", bundle: .module),
|
||||
value: "$\(biggestBet)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Balance Section
|
||||
|
||||
/// A reusable section showing starting/ending balance for a session.
|
||||
public struct BalanceSection: View {
|
||||
public let startingBalance: Int
|
||||
public let endingBalance: Int
|
||||
public let netResult: Int
|
||||
|
||||
public init(startingBalance: Int, endingBalance: Int, netResult: Int) {
|
||||
self.startingBalance = startingBalance
|
||||
self.endingBalance = endingBalance
|
||||
self.netResult = netResult
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: CasinoDesign.Spacing.medium) {
|
||||
HStack {
|
||||
Text(String(localized: "Starting balance", bundle: .module))
|
||||
Spacer()
|
||||
Text("$\(startingBalance)")
|
||||
.bold()
|
||||
}
|
||||
HStack {
|
||||
Text(String(localized: "Ending balance", bundle: .module))
|
||||
Spacer()
|
||||
Text("$\(endingBalance)")
|
||||
.foregroundStyle(netResult >= 0 ? .green : .red)
|
||||
.bold()
|
||||
}
|
||||
}
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.strong))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session Header
|
||||
|
||||
/// Header showing session date, end reason, net result, and win rate.
|
||||
public struct SessionDetailHeader: View {
|
||||
public let startTime: Date
|
||||
public let endReason: SessionEndReason?
|
||||
public let netResult: Int
|
||||
public let winRate: Double
|
||||
|
||||
public init(
|
||||
startTime: Date,
|
||||
endReason: SessionEndReason?,
|
||||
netResult: Int,
|
||||
winRate: Double
|
||||
) {
|
||||
self.startTime = startTime
|
||||
self.endReason = endReason
|
||||
self.netResult = netResult
|
||||
self.winRate = winRate
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: CasinoDesign.Spacing.small) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: CasinoDesign.Spacing.xxSmall) {
|
||||
Text(SessionFormatter.formatSessionDate(startTime))
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.body, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if let endReason = endReason {
|
||||
HStack(spacing: CasinoDesign.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", bundle: .module)
|
||||
: String(localized: "Ended manually", bundle: .module))
|
||||
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
|
||||
}
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.small))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: CasinoDesign.Spacing.xxSmall) {
|
||||
Text(SessionFormatter.formatMoney(netResult))
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.xLarge, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(netResult >= 0 ? .green : .red)
|
||||
|
||||
Text(SessionFormatter.formatPercent(winRate) + " " + String(localized: "win rate", bundle: .module))
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.small))
|
||||
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium)
|
||||
.fill(Color.Sheet.sectionFill)
|
||||
)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delete Session Button
|
||||
|
||||
/// A styled button for deleting a session.
|
||||
public struct DeleteSessionButton: View {
|
||||
public let action: () -> Void
|
||||
|
||||
public init(action: @escaping () -> Void) {
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Button(role: .destructive, action: action) {
|
||||
HStack {
|
||||
Image(systemName: "trash")
|
||||
Text(String(localized: "Delete Session", bundle: .module))
|
||||
}
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.body, weight: .medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(CasinoDesign.Spacing.medium)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium)
|
||||
.fill(Color.red.opacity(CasinoDesign.Opacity.hint))
|
||||
)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, CasinoDesign.Spacing.large)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Statistics Components") {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
StatisticsTabSelector(selectedTab: .constant(.current))
|
||||
|
||||
HStack(spacing: CasinoDesign.Spacing.large) {
|
||||
StatColumn(value: "15", label: "Sessions")
|
||||
StatColumn(value: "234", label: "Hands")
|
||||
StatColumn(value: "52.3%", label: "Win Rate", valueColor: .green)
|
||||
}
|
||||
.padding()
|
||||
|
||||
HStack(spacing: CasinoDesign.Spacing.medium) {
|
||||
OutcomeCircle(label: "Won", count: 45, color: .white)
|
||||
OutcomeCircle(label: "Lost", count: 38, color: .red)
|
||||
OutcomeCircle(label: "Push", count: 12, color: .gray)
|
||||
}
|
||||
.padding()
|
||||
|
||||
VStack(spacing: CasinoDesign.Spacing.medium) {
|
||||
StatRow(icon: "clock", label: "Total game time", value: "02h 15min")
|
||||
ChipStatRow(icon: "chart.line.uptrend.xyaxis", iconColor: .green, label: "Total gain", value: "$1,250")
|
||||
}
|
||||
.padding()
|
||||
|
||||
NoActiveSessionView()
|
||||
|
||||
EmptyHistoryView()
|
||||
}
|
||||
}
|
||||
.background(Color.Sheet.background)
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import SwiftUI
|
||||
/// Welcome sheet shown on first launch of a game.
|
||||
public struct WelcomeSheet: View {
|
||||
let gameName: String
|
||||
let gameEmoji: String
|
||||
let features: [WelcomeFeature]
|
||||
let onStartTutorial: () -> Void
|
||||
let onStartPlaying: () -> Void
|
||||
@ -22,11 +23,13 @@ public struct WelcomeSheet: View {
|
||||
|
||||
public init(
|
||||
gameName: String,
|
||||
gameEmoji: String = "🎰",
|
||||
features: [WelcomeFeature],
|
||||
onStartTutorial: @escaping () -> Void,
|
||||
onStartPlaying: @escaping () -> Void
|
||||
) {
|
||||
self.gameName = gameName
|
||||
self.gameEmoji = gameEmoji
|
||||
self.features = features
|
||||
self.onStartTutorial = onStartTutorial
|
||||
self.onStartPlaying = onStartPlaying
|
||||
@ -88,13 +91,6 @@ public struct WelcomeSheet: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var gameEmoji: String {
|
||||
switch gameName.lowercased() {
|
||||
case "blackjack": return "🃏"
|
||||
case "baccarat": return "🎴"
|
||||
default: return "🎰"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Feature Row
|
||||
@ -149,22 +145,23 @@ public struct WelcomeFeature: Identifiable {
|
||||
|
||||
#Preview {
|
||||
WelcomeSheet(
|
||||
gameName: "Blackjack",
|
||||
gameName: "Casino Game",
|
||||
gameEmoji: "🎰",
|
||||
features: [
|
||||
WelcomeFeature(
|
||||
icon: "target",
|
||||
title: "Beat the Dealer",
|
||||
description: "Get closer to 21 than the dealer without going over"
|
||||
title: "Exciting Gameplay",
|
||||
description: "Experience the thrill of the casino"
|
||||
),
|
||||
WelcomeFeature(
|
||||
icon: "lightbulb.fill",
|
||||
title: "Learn Strategy",
|
||||
description: "Built-in hints show optimal plays based on basic strategy"
|
||||
description: "Built-in hints show optimal plays"
|
||||
),
|
||||
WelcomeFeature(
|
||||
icon: "dollarsign.circle",
|
||||
title: "Practice Free",
|
||||
description: "Start with $1,000 and play risk-free"
|
||||
description: "Start with virtual chips and play risk-free"
|
||||
)
|
||||
],
|
||||
onStartTutorial: {},
|
||||
|
||||
@ -41,21 +41,8 @@ struct CardTests {
|
||||
#expect(deck.cardsRemaining == 52)
|
||||
}
|
||||
|
||||
@Test("Card baccarat values are correct")
|
||||
func baccaratValues() {
|
||||
#expect(Rank.ace.baccaratValue == 1)
|
||||
#expect(Rank.five.baccaratValue == 5)
|
||||
#expect(Rank.ten.baccaratValue == 0)
|
||||
#expect(Rank.king.baccaratValue == 0)
|
||||
}
|
||||
|
||||
@Test("Card blackjack values are correct")
|
||||
func blackjackValues() {
|
||||
#expect(Rank.ace.blackjackValue == 11)
|
||||
#expect(Rank.five.blackjackValue == 5)
|
||||
#expect(Rank.ten.blackjackValue == 10)
|
||||
#expect(Rank.king.blackjackValue == 10)
|
||||
}
|
||||
// Game-specific card value tests have been moved to the respective app test targets
|
||||
// (BlackjackTests and BaccaratTests) since the value extensions are now in the apps.
|
||||
|
||||
@Test("Card display format is correct")
|
||||
func cardDisplay() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user