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

This commit is contained in:
Matt Bruce 2025-12-29 14:38:54 -06:00
parent 178d28ca6c
commit abf4ba9b97
27 changed files with 1207 additions and 1228 deletions

View File

@ -45,12 +45,14 @@ struct BetResult: Identifiable {
} }
/// Main observable game state class managing all game logic and UI state. /// Main observable game state class managing all game logic and UI state.
/// Conforms to CasinoGameState for shared game behaviors.
@Observable @Observable
@MainActor @MainActor
final class GameState: SessionManagedGame { final class GameState: CasinoGameState {
// MARK: - SessionManagedGame // MARK: - CasinoGameState Conformance
typealias Stats = BaccaratStats typealias Stats = BaccaratStats
typealias GameSettingsType = GameSettings
/// The currently active session. /// The currently active session.
var currentSession: BaccaratSession? var currentSession: BaccaratSession?

View 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
}
}

View File

@ -7,6 +7,7 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import CasinoKit
/// The number of decks available for the shoe. /// The number of decks available for the shoe.
enum DeckCount: Int, CaseIterable, Identifiable { enum DeckCount: Int, CaseIterable, Identifiable {
@ -33,65 +34,13 @@ enum DeckCount: Int, CaseIterable, Identifiable {
} }
} }
/// Preset table limits for betting. // TableLimits is now provided by CasinoKit
enum TableLimits: String, CaseIterable, Identifiable {
case casual = "casual"
case low = "low"
case medium = "medium"
case high = "high"
case vip = "vip"
var id: String { rawValue } /// Observable settings class for Baccarat configuration.
/// Conforms to GameSettingsProtocol for shared settings behavior.
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 @Observable
@MainActor @MainActor
final class GameSettings { final class GameSettings: GameSettingsProtocol {
// MARK: - Deck Settings // MARK: - Deck Settings
/// Number of decks in the shoe. /// Number of decks in the shoe.

View File

@ -354,9 +354,6 @@
} }
} }
} }
},
"$%lld" : {
}, },
"2-9: Face value" : { "2-9: Face value" : {
"comment" : "Description of the card values for cards with values from 2 to 9.", "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!" : { "Avoid the Tie bet — 14.4% house edge!" : {
"comment" : "Tip for avoiding the Tie bet in baccarat, highlighting its low house edge.", "comment" : "Tip for avoiding the Tie bet in baccarat, highlighting its low house edge.",
"localizations" : { "localizations" : {
@ -1031,14 +1024,6 @@
"comment" : "Label for the number of banker win rounds in the statistics display.", "comment" : "Label for the number of banker win rounds in the statistics display.",
"isCommentAutoGenerated" : true "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" : { "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.", "comment" : "Title for the section in the statistics sheet that shows the user's performance on the Big Road.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Biggest bet" : {
"comment" : "The label for the \"Biggest bet\" row in the statistics sheet.",
"isCommentAutoGenerated" : true
},
"Blackjack" : { "Blackjack" : {
"comment" : "The name of a blackjack game.", "comment" : "The name of a blackjack game.",
"localizations" : { "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" : { "Customize Settings" : {
@ -1489,9 +1463,6 @@
"Delete" : { "Delete" : {
"comment" : "A button to delete a session.", "comment" : "A button to delete a session.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Delete Session" : {
}, },
"Delete Session?" : { "Delete Session?" : {
@ -1644,13 +1615,6 @@
"End Session?" : { "End Session?" : {
"comment" : "A confirmation dialog title.", "comment" : "A confirmation dialog title.",
"isCommentAutoGenerated" : true "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!)" : { "Example: 5♥ + 5♣ = Pair (wins!)" : {
"comment" : "Example of a pair bet winning.", "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" : { "Green Circle (T): Tie between Player and Banker" : {
"comment" : "Explains the green circle icon in the history.", "comment" : "Explains the green circle icon in the history.",
"localizations" : { "localizations" : {
@ -1919,10 +1879,6 @@
} }
} }
}, },
"History" : {
"comment" : "Title of the statistics tab that shows the user's session history.",
"isCommentAutoGenerated" : true
},
"HISTORY" : { "HISTORY" : {
"comment" : "A label displayed above the road map view, indicating that it shows a history of past game results.", "comment" : "A label displayed above the road map view, indicating that it shows a history of past game results.",
"localizations" : { "localizations" : {
@ -2332,10 +2288,6 @@
} }
} }
}, },
"Losing sessions" : {
"comment" : "A label describing the number of sessions the user has lost.",
"isCommentAutoGenerated" : true
},
"Lost" : { "Lost" : {
"comment" : "Labels for the outcome circles in the \"Win/Loss/Push\" section.", "comment" : "Labels for the outcome circles in the \"Win/Loss/Push\" section.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -2480,9 +2432,6 @@
"Net" : { "Net" : {
"comment" : "Label for the net winnings in the statistics sheet.", "comment" : "Label for the net winnings in the statistics sheet.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Net result" : {
}, },
"Never" : { "Never" : {
"localizations" : { "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" : { "No cards" : {
"comment" : "A description of the player's hand when they have no cards.", "comment" : "A description of the player's hand when they have no cards.",
"localizations" : { "localizations" : {
@ -2601,10 +2546,6 @@
} }
} }
}, },
"No Session History" : {
"comment" : "A description displayed when a user has no session history.",
"isCommentAutoGenerated" : true
},
"Objective" : { "Objective" : {
"comment" : "Title of a rule page in the \"Rules\" help view, describing the objective of the game.", "comment" : "Title of a rule page in the \"Rules\" help view, describing the objective of the game.",
"localizations" : { "localizations" : {
@ -3126,10 +3067,6 @@
"comment" : "A label for the \"Push\" outcome in the game stats section.", "comment" : "A label for the \"Push\" outcome in the game stats section.",
"isCommentAutoGenerated" : true "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" : { "Red Circle (B): Banker won the hand" : {
"comment" : "Explains the red circle icon in the history.", "comment" : "Explains the red circle icon in the history.",
"localizations" : { "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" : { "Start with $1,000 and play risk-free" : {
},
"Starting balance" : {
}, },
"STARTING BALANCE" : { "STARTING BALANCE" : {
"comment" : "Section header for starting balance settings.", "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" : { "Total game time" : {
"comment" : "Rows in the \"Game stats\" section of the statistics sheet, showing various statistics about a Baccarat session.", "comment" : "Rows in the \"Game stats\" section of the statistics sheet, showing various statistics about a Baccarat session.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -4269,10 +4191,6 @@
} }
} }
}, },
"win rate" : {
"comment" : "A label describing the win rate of a session.",
"isCommentAutoGenerated" : true
},
"Win Rate" : { "Win Rate" : {
"comment" : "Label for the win rate in the statistics sheet.", "comment" : "Label for the win rate in the statistics sheet.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -4300,22 +4218,10 @@
} }
} }
}, },
"Winning sessions" : {
"comment" : "A title describing the number of sessions the user has won.",
"isCommentAutoGenerated" : true
},
"Won" : { "Won" : {
"comment" : "Labels for the outcome circles in the \"Win/Loss/Push\" section.", "comment" : "Labels for the outcome circles in the \"Win/Loss/Push\" section.",
"isCommentAutoGenerated" : true "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" : { "Yellow Dot (bottom-left): A pair occurred in that hand" : {
"comment" : "Explains the yellow dot marker in the history.", "comment" : "Explains the yellow dot marker in the history.",
"localizations" : { "localizations" : {

View 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)
)
}

View File

@ -136,6 +136,7 @@ struct GameTableView: View {
.sheet(isPresented: $showWelcome) { .sheet(isPresented: $showWelcome) {
WelcomeSheet( WelcomeSheet(
gameName: "Baccarat", gameName: "Baccarat",
gameEmoji: "🎴",
features: [ features: [
WelcomeFeature( WelcomeFeature(
icon: "hand.raised.fill", icon: "hand.raised.fill",

View File

@ -19,8 +19,8 @@ struct StatisticsSheetView: View {
SheetContainerView( SheetContainerView(
title: String(localized: "Statistics"), title: String(localized: "Statistics"),
content: { content: {
// Tab selector // Tab selector (from CasinoKit)
tabSelector StatisticsTabSelector(selectedTab: $selectedTab)
// Content based on selected tab // Content based on selected tab
switch selectedTab { switch selectedTab {
@ -67,34 +67,7 @@ struct StatisticsSheetView: View {
} }
} }
// MARK: - Tab Selector // MARK: - Tab Selector (uses CasinoKit.StatisticsTabSelector)
private var tabSelector: some View {
HStack(spacing: 0) {
ForEach(StatisticsTab.allCases, id: \.self) { tab in
Button {
withAnimation(.spring(duration: Design.Animation.springDuration)) {
selectedTab = tab
}
} label: {
VStack(spacing: Design.Spacing.xSmall) {
Image(systemName: tab.icon)
.font(.system(size: Design.BaseFontSize.large))
Text(tab.title)
.font(.system(size: Design.BaseFontSize.small, weight: .medium))
}
.foregroundStyle(selectedTab == tab ? Color.Sheet.accent : .white.opacity(Design.Opacity.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, Design.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(selectedTab == tab ? Color.Sheet.accent.opacity(Design.Opacity.hint) : .clear)
)
}
}
}
.padding(.horizontal)
}
// MARK: - Current Session Content // MARK: - Current Session Content
@ -119,28 +92,10 @@ struct StatisticsSheetView: View {
bigRoadSection bigRoadSection
roadMapSection roadMapSection
} else { } 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 // MARK: - Global Stats Content
private var globalStatsContent: some View { 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") { SheetSection(title: String(localized: "SESSION PERFORMANCE"), icon: "chart.bar.fill") {
VStack(spacing: Design.Spacing.medium) { SessionPerformanceSection(
HStack { winningSessions: stats.winningSessions,
Text(String(localized: "Winning sessions")) losingSessions: stats.losingSessions,
Spacer() bestSession: stats.bestSession,
Text("\(stats.winningSessions)") worstSession: stats.worstSession
.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))
} }
} }
} }
@ -261,7 +187,7 @@ struct StatisticsSheetView: View {
private var sessionHistoryContent: some View { private var sessionHistoryContent: some View {
Group { Group {
if state.sessionHistory.isEmpty && state.currentSession == nil { if state.sessionHistory.isEmpty && state.currentSession == nil {
emptyHistoryView EmptyHistoryView()
} else { } else {
LazyVStack(spacing: Design.Spacing.medium) { LazyVStack(spacing: Design.Spacing.medium) {
// Current session at top if exists (taps go to Current tab) // 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 // 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") { SheetSection(title: String(localized: "CHIPS STATS"), icon: "dollarsign.circle.fill") {
VStack(spacing: Design.Spacing.medium) { ChipsStatsSection(
ChipStatRow( totalWinnings: session.totalWinnings,
icon: "chart.line.uptrend.xyaxis", biggestWin: session.biggestWin,
iconColor: session.totalWinnings >= 0 ? .green : .red, biggestLoss: session.biggestLoss,
label: String(localized: "Total gain"), totalBetAmount: session.totalBetAmount,
value: SessionFormatter.formatMoney(session.totalWinnings) averageBet: session.roundsPlayed > 0 ? session.averageBet : nil,
biggestBet: session.biggestBet
) )
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)"
)
}
} }
} }
@ -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 { // MARK: - Baccarat-Specific Views
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)
}
}
/// Compact win distribution indicator for Player/Banker/Tie.
private struct WinStatCompact: View { private struct WinStatCompact: View {
let label: String let label: String
let count: Int let count: Int
let color: Color let color: Color
private let indicatorSize: CGFloat = 24
var body: some View { var body: some View {
VStack(spacing: Design.Spacing.xSmall) { VStack(spacing: CasinoDesign.Spacing.xSmall) {
Circle() Circle()
.fill(color) .fill(color)
.frame(width: Size.winIndicatorSize, height: Size.winIndicatorSize) .frame(width: indicatorSize, height: indicatorSize)
Text("\(count)") Text("\(count)")
.font(.system(size: Design.BaseFontSize.large, weight: .bold)) .font(.system(size: CasinoDesign.BaseFontSize.large, weight: .bold))
.foregroundStyle(.white) .foregroundStyle(.white)
Text(label) Text(label)
.font(.system(size: Design.BaseFontSize.small)) .font(.system(size: CasinoDesign.BaseFontSize.small))
.foregroundStyle(color) .foregroundStyle(color)
} }
.frame(maxWidth: .infinity) .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 // MARK: - Session Detail View
private struct SessionDetailView: View { private struct SessionDetailView: View {
@ -673,8 +417,13 @@ private struct SessionDetailView: View {
SheetContainerView( SheetContainerView(
title: styleDisplayName, title: styleDisplayName,
content: { content: {
// Session header info // Session header info (from CasinoKit)
sessionHeader SessionDetailHeader(
startTime: session.startTime,
endReason: session.endReason,
netResult: session.netResult,
winRate: session.winRate
)
// Game stats section // Game stats section
SheetSection(title: String(localized: "GAME STATS"), icon: "gamecontroller.fill") { SheetSection(title: String(localized: "GAME STATS"), icon: "gamecontroller.fill") {
@ -689,7 +438,7 @@ private struct SessionDetailView: View {
.foregroundStyle(.white) .foregroundStyle(.white)
} }
// Win/Loss/Push // Win/Loss/Push (from CasinoKit)
HStack(spacing: Design.Spacing.medium) { HStack(spacing: Design.Spacing.medium) {
OutcomeCircle(label: String(localized: "Won"), count: session.wins, color: .white) OutcomeCircle(label: String(localized: "Won"), count: session.wins, color: .white)
OutcomeCircle(label: String(localized: "Lost"), count: session.losses, color: .red) 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)) 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)) StatRow(icon: "clock", label: String(localized: "Total game time"), value: SessionFormatter.formatDuration(session.duration))
// Baccarat-specific stats // Baccarat-specific stats
@ -708,7 +457,7 @@ private struct SessionDetailView: View {
Divider().background(Color.white.opacity(Design.Opacity.hint)) Divider().background(Color.white.opacity(Design.Opacity.hint))
// Win distribution // Win distribution (Baccarat-specific)
HStack(spacing: Design.Spacing.large) { HStack(spacing: Design.Spacing.large) {
WinStatCompact( WinStatCompact(
label: String(localized: "Player"), 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") { SheetSection(title: String(localized: "CHIPS STATS"), icon: "dollarsign.circle.fill") {
VStack(spacing: Design.Spacing.medium) { ChipsStatsSection(
ChipStatRow( totalWinnings: session.totalWinnings,
icon: "chart.line.uptrend.xyaxis", biggestWin: session.biggestWin,
iconColor: session.totalWinnings >= 0 ? .green : .red, biggestLoss: session.biggestLoss,
label: String(localized: "Net result"), totalBetAmount: session.totalBetAmount,
value: SessionFormatter.formatMoney(session.totalWinnings) averageBet: session.roundsPlayed > 0 ? session.averageBet : nil,
) biggestBet: session.biggestBet
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( // Balance section (from CasinoKit)
icon: "star.circle.fill",
iconColor: .orange,
label: String(localized: "Biggest bet"),
value: "$\(session.biggestBet)"
)
}
}
// Balance section
SheetSection(title: String(localized: "BALANCE"), icon: "banknote.fill") { SheetSection(title: String(localized: "BALANCE"), icon: "banknote.fill") {
VStack(spacing: Design.Spacing.medium) { BalanceSection(
HStack { startingBalance: session.startingBalance,
Text(String(localized: "Starting balance")) endingBalance: session.endingBalance,
Spacer() netResult: session.netResult
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))
} }
// Big Road section // Big Road section (Baccarat-specific)
if !roundHistory.isEmpty { if !roundHistory.isEmpty {
SheetSection(title: String(localized: "BIG ROAD"), icon: "chart.bar.xaxis") { SheetSection(title: String(localized: "BIG ROAD"), icon: "chart.bar.xaxis") {
BigRoadView(results: roundHistory) BigRoadView(results: roundHistory)
@ -827,25 +525,10 @@ private struct SessionDetailView: View {
} }
} }
// Delete button // Delete button (from CasinoKit)
Button(role: .destructive) { DeleteSessionButton {
showDeleteConfirmation = true 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, onCancel: nil,
onDone: { dismiss() }, onDone: { dismiss() },
@ -865,48 +548,6 @@ private struct SessionDetailView: View {
Text(String(localized: "This will permanently remove this session from your history.")) 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 // MARK: - Big Road View

View File

@ -19,12 +19,14 @@ enum GamePhase: Equatable {
} }
/// Main game state manager. /// Main game state manager.
/// Conforms to CasinoGameState for shared game behaviors.
@Observable @Observable
@MainActor @MainActor
final class GameState: SessionManagedGame { final class GameState: CasinoGameState {
// MARK: - SessionManagedGame // MARK: - CasinoGameState Conformance
typealias Stats = BlackjackStats typealias Stats = BlackjackStats
typealias GameSettingsType = GameSettings
/// Current player balance. /// Current player balance.
var balance: Int var balance: Int

View File

@ -69,9 +69,10 @@ enum DeckCount: Int, CaseIterable, Identifiable {
} }
/// Observable settings class for Blackjack configuration. /// Observable settings class for Blackjack configuration.
/// Conforms to GameSettingsProtocol for shared settings behavior.
@Observable @Observable
@MainActor @MainActor
final class GameSettings { final class GameSettings: GameSettingsProtocol {
// MARK: - Game Style // MARK: - Game Style
/// The preset rule variation. /// The preset rule variation.

View File

@ -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." : { "1 Deck: Lowest house edge (~0.17%), rare to find." : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -1088,10 +1084,6 @@
} }
} }
}, },
"Average bet" : {
"comment" : "Label for the average bet value in the Statistics Sheet.",
"isCommentAutoGenerated" : true
},
"Baccarat" : { "Baccarat" : {
"comment" : "The name of a casino game.", "comment" : "The name of a casino game.",
"isCommentAutoGenerated" : true, "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" : { "Bet 2x minimum" : {
"comment" : "Betting recommendation based on a true count of 1.", "comment" : "Betting recommendation based on a true count of 1.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -1508,10 +1492,6 @@
} }
} }
}, },
"Biggest bet" : {
"comment" : "Label for the \"Biggest bet\" row in the Statistics Sheet.",
"isCommentAutoGenerated" : true
},
"BIGGEST SWINGS" : { "BIGGEST SWINGS" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "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)" : { "Cost: $%lld (half your bet)" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -2176,9 +2152,6 @@
} }
} }
} }
},
"Current" : {
}, },
"Current bet $%lld" : { "Current bet $%lld" : {
"comment" : "A hint that appears when a user taps on a side bet zone. The text varies depending on whether a bet is currently placed or not.", "comment" : "A hint that appears when a user taps on a side bet zone. The text varies depending on whether a bet is currently placed or not.",
@ -2663,9 +2636,6 @@
"Delete" : { "Delete" : {
"comment" : "A button label that deletes a session.", "comment" : "A button label that deletes a session.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Delete Session" : {
}, },
"Delete Session?" : { "Delete Session?" : {
@ -3151,14 +3121,6 @@
"comment" : "A confirmation dialog title that asks if the user wants to end their current session.", "comment" : "A confirmation dialog title that asks if the user wants to end their current session.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Ended manually" : {
"comment" : "A description of a session that ended manually (e.g. by the user closing the game).",
"isCommentAutoGenerated" : true
},
"Ending balance" : {
"comment" : "A label displayed below the user's ending balance in the session detail view.",
"isCommentAutoGenerated" : true
},
"European" : { "European" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -3483,10 +3445,6 @@
}, },
"Get closer to 21 than the dealer without going over" : { "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" : { "H17 rule, increases house edge" : {
"localizations" : { "localizations" : {
@ -3630,10 +3588,6 @@
} }
} }
}, },
"History" : {
"comment" : "Title of the statistics tab that shows the user's session history.",
"isCommentAutoGenerated" : true
},
"Hit" : { "Hit" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -4256,10 +4210,6 @@
} }
} }
}, },
"Losing sessions" : {
"comment" : "A label describing the number of sessions that the user lost.",
"isCommentAutoGenerated" : true
},
"Losses" : { "Losses" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "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" : { "Never" : {
"localizations" : { "localizations" : {
"en" : { "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" : { "No Hand" : {
"comment" : "Description of a 21+3 side bet outcome when there is no qualifying hand.", "comment" : "Description of a 21+3 side bet outcome when there is no qualifying hand.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -4727,10 +4669,6 @@
} }
} }
}, },
"No Session History" : {
"comment" : "A message displayed when a user has no session history.",
"isCommentAutoGenerated" : true
},
"No surrender option." : { "No surrender option." : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -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" : { "Re-split Aces" : {
"localizations" : { "localizations" : {
"en" : { "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" : { "STARTING BALANCE" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -6879,14 +6805,6 @@
"comment" : "Label for the duration of a blackjack game.", "comment" : "Label for the duration of a blackjack game.",
"isCommentAutoGenerated" : true "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" : { "Total game time" : {
"comment" : "Label for a stat row displaying the total game time.", "comment" : "Label for a stat row displaying the total game time.",
"isCommentAutoGenerated" : true "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" : { "Win Rate" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -7173,10 +7087,6 @@
} }
} }
}, },
"Winning sessions" : {
"comment" : "A label describing the number of sessions the user has won.",
"isCommentAutoGenerated" : true
},
"Wins" : { "Wins" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "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)" : { "Yes ($%lld)" : {
"localizations" : { "localizations" : {
"en" : { "en" : {

View 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"
)
}

View File

@ -86,6 +86,7 @@ struct GameTableView: View {
.sheet(isPresented: $showWelcome) { .sheet(isPresented: $showWelcome) {
WelcomeSheet( WelcomeSheet(
gameName: "Blackjack", gameName: "Blackjack",
gameEmoji: "🃏",
features: [ features: [
WelcomeFeature( WelcomeFeature(
icon: "target", icon: "target",

View File

@ -19,8 +19,8 @@ struct StatisticsSheetView: View {
SheetContainerView( SheetContainerView(
title: String(localized: "Statistics"), title: String(localized: "Statistics"),
content: { content: {
// Tab selector // Tab selector (from CasinoKit)
tabSelector StatisticsTabSelector(selectedTab: $selectedTab)
// Content based on selected tab // Content based on selected tab
switch selectedTab { switch selectedTab {
@ -63,34 +63,7 @@ struct StatisticsSheetView: View {
} }
} }
// MARK: - Tab Selector // MARK: - Tab Selector (uses CasinoKit.StatisticsTabSelector)
private var tabSelector: some View {
HStack(spacing: 0) {
ForEach(StatisticsTab.allCases, id: \.self) { tab in
Button {
withAnimation(.spring(duration: Design.Animation.springDuration)) {
selectedTab = tab
}
} label: {
VStack(spacing: Design.Spacing.xSmall) {
Image(systemName: tab.icon)
.font(.system(size: Design.BaseFontSize.large))
Text(tab.title)
.font(.system(size: Design.BaseFontSize.small, weight: .medium))
}
.foregroundStyle(selectedTab == tab ? Color.Sheet.accent : .white.opacity(Design.Opacity.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, Design.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(selectedTab == tab ? Color.Sheet.accent.opacity(Design.Opacity.hint) : .clear)
)
}
}
}
.padding(.horizontal)
}
// MARK: - Current Session Content // MARK: - Current Session Content
@ -111,28 +84,10 @@ struct StatisticsSheetView: View {
// Session stats // Session stats
sessionStatsSection(session: session) sessionStatsSection(session: session)
} else { } 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 // MARK: - Global Stats Content
private var globalStatsContent: some View { 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") { SheetSection(title: String(localized: "SESSION PERFORMANCE"), icon: "chart.bar.fill") {
VStack(spacing: Design.Spacing.medium) { SessionPerformanceSection(
HStack { winningSessions: stats.winningSessions,
Text(String(localized: "Winning sessions")) losingSessions: stats.losingSessions,
Spacer() bestSession: stats.bestSession,
Text("\(stats.winningSessions)") worstSession: stats.worstSession
.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))
} }
} }
} }
@ -232,7 +158,7 @@ struct StatisticsSheetView: View {
private var sessionHistoryContent: some View { private var sessionHistoryContent: some View {
Group { Group {
if state.sessionHistory.isEmpty && state.currentSession == nil { if state.sessionHistory.isEmpty && state.currentSession == nil {
emptyHistoryView EmptyHistoryView()
} else { } else {
LazyVStack(spacing: Design.Spacing.medium) { LazyVStack(spacing: Design.Spacing.medium) {
// Current session at top if exists (taps go to Current tab) // 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 // 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") { SheetSection(title: String(localized: "CHIPS STATS"), icon: "dollarsign.circle.fill") {
VStack(spacing: Design.Spacing.medium) { ChipsStatsSection(
ChipStatRow( totalWinnings: session.totalWinnings,
icon: "chart.line.uptrend.xyaxis", biggestWin: session.biggestWin,
iconColor: session.totalWinnings >= 0 ? .green : .red, biggestLoss: session.biggestLoss,
label: String(localized: "Total gain"), totalBetAmount: session.totalBetAmount,
value: SessionFormatter.formatMoney(session.totalWinnings) averageBet: session.roundsPlayed > 0 ? session.averageBet : nil,
biggestBet: session.biggestBet
) )
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)"
)
}
} }
} }
@ -405,139 +275,8 @@ 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)
}
}
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 // MARK: - Session Detail View
@ -553,8 +292,13 @@ private struct SessionDetailView: View {
SheetContainerView( SheetContainerView(
title: styleDisplayName, title: styleDisplayName,
content: { content: {
// Session header info // Session header info (from CasinoKit)
sessionHeader SessionDetailHeader(
startTime: session.startTime,
endReason: session.endReason,
netResult: session.netResult,
winRate: session.winRate
)
// Game stats section // Game stats section
SheetSection(title: String(localized: "GAME STATS"), icon: "gamecontroller.fill") { SheetSection(title: String(localized: "GAME STATS"), icon: "gamecontroller.fill") {
@ -569,7 +313,7 @@ private struct SessionDetailView: View {
.foregroundStyle(.white) .foregroundStyle(.white)
} }
// Win/Loss/Push // Win/Loss/Push (from CasinoKit)
HStack(spacing: Design.Spacing.medium) { HStack(spacing: Design.Spacing.medium) {
OutcomeCircle(label: String(localized: "Won"), count: session.wins, color: .white) OutcomeCircle(label: String(localized: "Won"), count: session.wins, color: .white)
OutcomeCircle(label: String(localized: "Lost"), count: session.losses, color: .red) 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)) 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)) StatRow(icon: "clock", label: String(localized: "Total game time"), value: SessionFormatter.formatDuration(session.duration))
// Blackjack-specific stats // 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") { SheetSection(title: String(localized: "CHIPS STATS"), icon: "dollarsign.circle.fill") {
VStack(spacing: Design.Spacing.medium) { ChipsStatsSection(
ChipStatRow( totalWinnings: session.totalWinnings,
icon: "chart.line.uptrend.xyaxis", biggestWin: session.biggestWin,
iconColor: session.totalWinnings >= 0 ? .green : .red, biggestLoss: session.biggestLoss,
label: String(localized: "Net result"), totalBetAmount: session.totalBetAmount,
value: SessionFormatter.formatMoney(session.totalWinnings) averageBet: session.roundsPlayed > 0 ? session.averageBet : nil,
) biggestBet: session.biggestBet
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( // Balance section (from CasinoKit)
icon: "star.circle.fill",
iconColor: .orange,
label: String(localized: "Biggest bet"),
value: "$\(session.biggestBet)"
)
}
}
// Balance section
SheetSection(title: String(localized: "BALANCE"), icon: "banknote.fill") { SheetSection(title: String(localized: "BALANCE"), icon: "banknote.fill") {
VStack(spacing: Design.Spacing.medium) { BalanceSection(
HStack { startingBalance: session.startingBalance,
Text(String(localized: "Starting balance")) endingBalance: session.endingBalance,
Spacer() netResult: session.netResult
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 // Delete button (from CasinoKit)
Button(role: .destructive) { DeleteSessionButton {
showDeleteConfirmation = true 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, onCancel: nil,
onDone: { dismiss() }, onDone: { dismiss() },
@ -698,57 +376,6 @@ private struct SessionDetailView: View {
Text(String(localized: "This will permanently remove this session from your history.")) 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 // MARK: - Preview

View File

@ -15,6 +15,8 @@
// - TableLimits // - TableLimits
// - OnboardingState // - OnboardingState
// - TooltipManager, TooltipConfig // - TooltipManager, TooltipConfig
// - GameSettingsProtocol (shared settings interface)
// - SettingsKeys, SettingsDefaults (persistence helpers)
// MARK: - Views // MARK: - Views
// - CardView, CardFrontView, CardBackView, CardPlaceholderView // - CardView, CardFrontView, CardBackView, CardPlaceholderView
@ -71,6 +73,7 @@
// - SelectionIndicator (checkmark circle) // - SelectionIndicator (checkmark circle)
// - BadgePill (capsule badge for values) // - BadgePill (capsule badge for values)
// MARK: - Branding // MARK: - Branding
// - AppIconView, AppIconConfig // - AppIconView, AppIconConfig
// - LaunchScreenView, LaunchScreenConfig, StaticLaunchScreenView // - LaunchScreenView, LaunchScreenConfig, StaticLaunchScreenView
@ -95,6 +98,7 @@
// - GameSession<Stats> (generic session with game-specific stats) // - GameSession<Stats> (generic session with game-specific stats)
// - GameSpecificStats (protocol for game-specific statistics) // - GameSpecificStats (protocol for game-specific statistics)
// - SessionManagedGame (protocol for games with session management) // - SessionManagedGame (protocol for games with session management)
// - CasinoGameState (protocol extending SessionManagedGame with shared behaviors)
// - SessionEndReason (.manualEnd, .brokeOut) // - SessionEndReason (.manualEnd, .brokeOut)
// - RoundOutcome (.win, .lose, .push) // - RoundOutcome (.win, .lose, .push)
// - AggregatedSessionStats (combined stats from multiple sessions) // - AggregatedSessionStats (combined stats from multiple sessions)
@ -104,6 +108,21 @@
// - CurrentSessionHeader, SessionSummaryRow (UI components) // - CurrentSessionHeader, SessionSummaryRow (UI components)
// - GameStatRow (display a stat item) // - 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 // MARK: - Debug
// - debugBorder(_:color:label:) View modifier // - debugBorder(_:color:label:) View modifier

View File

@ -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). // Game-specific value properties (baccaratValue, blackjackValue) should be
public var baccaratValue: Int { // defined as extensions in the respective game apps.
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
}
}
/// Accessibility name for VoiceOver. /// Accessibility name for VoiceOver.
public var accessibilityName: String { public var accessibilityName: String {
@ -123,15 +105,7 @@ public struct Card: Identifiable, Equatable, Sendable {
self.rank = rank self.rank = rank
} }
/// The baccarat point value of this card. // Game-specific value properties should be defined as extensions in game apps.
public var baccaratValue: Int {
rank.baccaratValue
}
/// The blackjack value of this card.
public var blackjackValue: Int {
rank.blackjackValue
}
/// Display string showing rank and suit together. /// Display string showing rank and suit together.
public var display: String { public var display: String {

View 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
}
}

View 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
}

View File

@ -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" : { "$%lld bet" : {
"comment" : "A value describing the bet amount in the accessibility label. The argument is the bet amount.", "comment" : "A value describing the bet amount in the accessibility label. The argument is the bet amount.",
"extractionState" : "stale", "extractionState" : "stale",
@ -431,6 +435,10 @@
} }
} }
}, },
"Average bet" : {
"comment" : "Label for the average bet value in the ChipsStatsSection.",
"isCommentAutoGenerated" : true
},
"Balance" : { "Balance" : {
"localizations" : { "localizations" : {
"en" : { "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" : { "Betting disabled" : {
"comment" : "A hint that appears when a betting zone is disabled.", "comment" : "A hint that appears when a betting zone is disabled.",
"extractionState" : "stale", "extractionState" : "stale",
@ -482,6 +498,10 @@
"comment" : "The accessibility label for the betting hint view.", "comment" : "The accessibility label for the betting hint view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Biggest bet" : {
"comment" : "Label for the \"Biggest bet\" statistic in the ChipsStatsSection.",
"isCommentAutoGenerated" : true
},
"Bust" : { "Bust" : {
"comment" : "A string describing when a player busts out of a game.", "comment" : "A string describing when a player busts out of a game.",
"isCommentAutoGenerated" : true "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" : { "Contact Us" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -671,6 +695,10 @@
} }
} }
}, },
"Current" : {
"comment" : "Title of the \"Current\" tab in the statistics view.",
"isCommentAutoGenerated" : true
},
"Current Session" : { "Current Session" : {
"comment" : "A label for the header of the current session section.", "comment" : "A label for the header of the current session section.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -748,6 +776,10 @@
} }
} }
}, },
"Delete Session" : {
"comment" : "A button label that deletes a session.",
"isCommentAutoGenerated" : true
},
"Diamonds" : { "Diamonds" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -899,6 +931,14 @@
"comment" : "A label indicating that a session has ended.", "comment" : "A label indicating that a session has ended.",
"isCommentAutoGenerated" : true "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" : { "Exclusive VIP room" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -1059,6 +1099,10 @@
} }
} }
}, },
"Global" : {
"comment" : "Title for the \"Global\" tab in the statistics view.",
"isCommentAutoGenerated" : true
},
"Got it" : { "Got it" : {
}, },
@ -1132,6 +1176,10 @@
} }
} }
}, },
"History" : {
"comment" : "Title of the History tab in the Statistics sheet.",
"isCommentAutoGenerated" : true
},
"iCloud Sync" : { "iCloud Sync" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -1374,6 +1422,10 @@
} }
} }
}, },
"Losing sessions" : {
"comment" : "A label for the number of sessions a user has lost.",
"isCommentAutoGenerated" : true
},
"Low Stakes" : { "Low Stakes" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -1495,6 +1547,10 @@
} }
} }
}, },
"No Active Session" : {
"comment" : "A label describing a state where there is no active session.",
"isCommentAutoGenerated" : true
},
"No bet" : { "No bet" : {
"comment" : "A description of a zone with no active bet.", "comment" : "A description of a zone with no active bet.",
"extractionState" : "stale", "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." : { "Our apps do not integrate with third-party services that collect user data. We do not share any information with third parties." : {
"localizations" : { "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" : { "Regular casino table" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -1924,6 +1987,14 @@
}, },
"Start Playing" : { "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" : { "Statistics" : {
"localizations" : { "localizations" : {
@ -2039,6 +2110,14 @@
"comment" : "A label displayed alongside the total winnings in the result banner.", "comment" : "A label displayed alongside the total winnings in the result banner.",
"isCommentAutoGenerated" : true "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" : { "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.", "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 "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" : { "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.", "comment" : "Text in the Privacy Policy View explaining how to disable iCloud sync in the app settings.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,

View File

@ -33,31 +33,17 @@ public struct AppIconConfig: Sendable {
self.accentColor = accentColor 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. /// Poker game icon configuration (example preset).
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.
public static let poker = AppIconConfig( public static let poker = AppIconConfig(
title: "POKER", title: "POKER",
iconSymbol: "suit.diamond.fill", iconSymbol: "suit.diamond.fill",
accentColor: .red accentColor: .red
) )
/// Roulette game icon configuration. /// Roulette game icon configuration (example preset).
public static let roulette = AppIconConfig( public static let roulette = AppIconConfig(
title: "ROULETTE", title: "ROULETTE",
iconSymbol: "circle.grid.3x3.fill", iconSymbol: "circle.grid.3x3.fill",
@ -195,22 +181,20 @@ private struct DiamondPatternOverlay: View {
// MARK: - Preview // MARK: - Preview
#Preview("Baccarat Icon") { #Preview("Poker Icon") {
AppIconView(config: .baccarat, size: 512) AppIconView(config: .poker, size: 512)
.padding() .padding()
.background(Color.gray) .background(Color.gray)
} }
#Preview("Blackjack Icon") { #Preview("Roulette Icon") {
AppIconView(config: .blackjack, size: 512) AppIconView(config: .roulette, size: 512)
.padding() .padding()
.background(Color.gray) .background(Color.gray)
} }
#Preview("All Icons") { #Preview("All Icons") {
HStack(spacing: 20) { HStack(spacing: 20) {
AppIconView(config: .baccarat, size: 200)
AppIconView(config: .blackjack, size: 200)
AppIconView(config: .poker, size: 200) AppIconView(config: .poker, size: 200)
AppIconView(config: .roulette, size: 200) AppIconView(config: .roulette, size: 200)
} }

View File

@ -134,6 +134,6 @@ public struct IconExportView: View {
} }
#Preview("Icon Export") { #Preview("Icon Export") {
IconExportView(config: .baccarat) IconExportView(config: .poker)
} }

View File

@ -38,26 +38,10 @@ public struct LaunchScreenConfig: Sendable {
self.showLoadingIndicator = showLoadingIndicator 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. /// Poker game launch screen configuration (example preset).
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.
public static let poker = LaunchScreenConfig( public static let poker = LaunchScreenConfig(
title: "POKER", title: "POKER",
tagline: "Texas Hold'em", tagline: "Texas Hold'em",
@ -339,15 +323,11 @@ public struct StaticLaunchScreenView: View {
// MARK: - Preview // MARK: - Preview
#Preview("Baccarat Launch") { #Preview("Poker Launch") {
LaunchScreenView(config: .baccarat) LaunchScreenView(config: .poker)
}
#Preview("Blackjack Launch") {
LaunchScreenView(config: .blackjack)
} }
#Preview("Static Launch") { #Preview("Static Launch") {
StaticLaunchScreenView(config: .baccarat) StaticLaunchScreenView(config: .poker)
} }

View File

@ -142,7 +142,7 @@ public enum ActionButtonStyle {
} }
} }
#Preview("BlackJack Action Buttons") { #Preview("Casino Action Buttons") {
ZStack { ZStack {
Color.CasinoTable.felt.ignoresSafeArea() Color.CasinoTable.felt.ignoresSafeArea()

View File

@ -76,14 +76,14 @@ public struct GameOverView: View {
// Stats card // Stats card
VStack(spacing: CasinoDesign.Spacing.medium) { VStack(spacing: CasinoDesign.Spacing.medium) {
StatRow( GameOverStatRow(
label: String(localized: "Rounds Played", bundle: .module), label: String(localized: "Rounds Played", bundle: .module),
value: "\(roundsPlayed)", value: "\(roundsPlayed)",
fontSize: statsFontSize fontSize: statsFontSize
) )
ForEach(additionalStats.indices, id: \.self) { index in ForEach(additionalStats.indices, id: \.self) { index in
StatRow( GameOverStatRow(
label: additionalStats[index].0, label: additionalStats[index].0,
value: additionalStats[index].1, value: additionalStats[index].1,
fontSize: statsFontSize fontSize: statsFontSize
@ -170,7 +170,7 @@ public struct GameOverView: View {
} }
/// A single stat row for the game over view. /// A single stat row for the game over view.
private struct StatRow: View { private struct GameOverStatRow: View {
let label: String let label: String
let value: String let value: String
let fontSize: CGFloat let fontSize: CGFloat

View File

@ -332,25 +332,8 @@ public struct CurrentSessionHeader: View {
} }
} }
// MARK: - Stat Column (Helper) // MARK: - Stat Column
// StatColumn is now provided by StatisticsComponents.swift
private struct StatColumn: View {
let value: String
let label: String
var valueColor: Color = .white
var body: some View {
VStack(spacing: CasinoDesign.Spacing.xSmall) {
Text(value)
.font(.system(size: CasinoDesign.BaseFontSize.large, weight: .bold, design: .rounded))
.foregroundStyle(valueColor)
Text(label)
.font(.system(size: CasinoDesign.BaseFontSize.small))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Game Stats Display Row // MARK: - Game Stats Display Row

View File

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

View File

@ -10,6 +10,7 @@ import SwiftUI
/// Welcome sheet shown on first launch of a game. /// Welcome sheet shown on first launch of a game.
public struct WelcomeSheet: View { public struct WelcomeSheet: View {
let gameName: String let gameName: String
let gameEmoji: String
let features: [WelcomeFeature] let features: [WelcomeFeature]
let onStartTutorial: () -> Void let onStartTutorial: () -> Void
let onStartPlaying: () -> Void let onStartPlaying: () -> Void
@ -22,11 +23,13 @@ public struct WelcomeSheet: View {
public init( public init(
gameName: String, gameName: String,
gameEmoji: String = "🎰",
features: [WelcomeFeature], features: [WelcomeFeature],
onStartTutorial: @escaping () -> Void, onStartTutorial: @escaping () -> Void,
onStartPlaying: @escaping () -> Void onStartPlaying: @escaping () -> Void
) { ) {
self.gameName = gameName self.gameName = gameName
self.gameEmoji = gameEmoji
self.features = features self.features = features
self.onStartTutorial = onStartTutorial self.onStartTutorial = onStartTutorial
self.onStartPlaying = onStartPlaying 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 // MARK: - Feature Row
@ -149,22 +145,23 @@ public struct WelcomeFeature: Identifiable {
#Preview { #Preview {
WelcomeSheet( WelcomeSheet(
gameName: "Blackjack", gameName: "Casino Game",
gameEmoji: "🎰",
features: [ features: [
WelcomeFeature( WelcomeFeature(
icon: "target", icon: "target",
title: "Beat the Dealer", title: "Exciting Gameplay",
description: "Get closer to 21 than the dealer without going over" description: "Experience the thrill of the casino"
), ),
WelcomeFeature( WelcomeFeature(
icon: "lightbulb.fill", icon: "lightbulb.fill",
title: "Learn Strategy", title: "Learn Strategy",
description: "Built-in hints show optimal plays based on basic strategy" description: "Built-in hints show optimal plays"
), ),
WelcomeFeature( WelcomeFeature(
icon: "dollarsign.circle", icon: "dollarsign.circle",
title: "Practice Free", title: "Practice Free",
description: "Start with $1,000 and play risk-free" description: "Start with virtual chips and play risk-free"
) )
], ],
onStartTutorial: {}, onStartTutorial: {},

View File

@ -41,21 +41,8 @@ struct CardTests {
#expect(deck.cardsRemaining == 52) #expect(deck.cardsRemaining == 52)
} }
@Test("Card baccarat values are correct") // Game-specific card value tests have been moved to the respective app test targets
func baccaratValues() { // (BlackjackTests and BaccaratTests) since the value extensions are now in the apps.
#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)
}
@Test("Card display format is correct") @Test("Card display format is correct")
func cardDisplay() { func cardDisplay() {