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

This commit is contained in:
Matt Bruce 2025-12-16 22:48:08 -06:00
parent 754654665c
commit 544b2b9a86
12 changed files with 1224 additions and 157 deletions

View File

@ -385,7 +385,9 @@ final class GameState {
roundHistory.append(RoundResult(
result: result,
playerValue: playerHandValue,
bankerValue: bankerHandValue
bankerValue: bankerHandValue,
playerPair: playerHadPair,
bankerPair: bankerHadPair
))
// Show result banner - stays until user taps New Round

View File

@ -55,12 +55,32 @@ struct RoundResult: Identifiable {
let result: GameResult
let playerValue: Int
let bankerValue: Int
let playerPair: Bool
let bankerPair: Bool
let timestamp: Date
init(result: GameResult, playerValue: Int, bankerValue: Int) {
/// Whether either hand was a natural (8 or 9).
var isNatural: Bool {
playerValue >= 8 || bankerValue >= 8
}
/// Whether any pair occurred.
var hasPair: Bool {
playerPair || bankerPair
}
init(
result: GameResult,
playerValue: Int,
bankerValue: Int,
playerPair: Bool = false,
bankerPair: Bool = false
) {
self.result = result
self.playerValue = playerValue
self.bankerValue = bankerValue
self.playerPair = playerPair
self.bankerPair = bankerPair
self.timestamp = .now
}
}

View File

@ -166,6 +166,46 @@
}
}
},
"B Pair" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "B Pair"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Par B"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Par B"
}
},
"es-US" : {
"stringUnit" : {
"state" : "translated",
"value" : "Par B"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Paire B"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Paire B"
}
}
}
},
"BACCARAT" : {
},
@ -213,6 +253,46 @@
}
}
},
"Banker" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Banker"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Banca"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Banca"
}
},
"es-US" : {
"stringUnit" : {
"state" : "translated",
"value" : "Banca"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Banquier"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Banquier"
}
}
}
},
"BANKER" : {
"comment" : "A label displayed above the banker's hand in the baccarat table view.",
"localizations" : {
@ -671,6 +751,46 @@
"comment" : "A section header for information about winning with a natural hand.",
"isCommentAutoGenerated" : true
},
"Naturals" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Naturals"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Naturales"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Naturales"
}
},
"es-US" : {
"stringUnit" : {
"state" : "translated",
"value" : "Naturales"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Naturels"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Naturels"
}
}
}
},
"New Round" : {
"comment" : "The label of a button that starts a new round of the game.",
"localizations" : {
@ -714,6 +834,86 @@
"comment" : "Summary text for a road map view when there is no history.",
"isCommentAutoGenerated" : true
},
"No rounds played yet" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No rounds played yet"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aún no hay rondas jugadas"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aún no hay rondas jugadas"
}
},
"es-US" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aún no hay rondas jugadas"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aucune partie jouée"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aucune partie jouée"
}
}
}
},
"P Pair" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "P Pair"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Par J"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Par J"
}
},
"es-US" : {
"stringUnit" : {
"state" : "translated",
"value" : "Par J"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Paire J"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Paire J"
}
}
}
},
"Pair Bonus" : {
"comment" : "Title of the page explaining the pair bonus in Baccarat.",
"isCommentAutoGenerated" : true
@ -872,6 +1072,46 @@
}
}
},
"Player" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Player"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Jugador"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Jugador"
}
},
"es-US" : {
"stringUnit" : {
"state" : "translated",
"value" : "Jugador"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Joueur"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Joueur"
}
}
}
},
"PLAYER" : {
"comment" : "The label for the player's hand in the cards display area.",
"localizations" : {
@ -1026,6 +1266,46 @@
}
}
},
"Rounds" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rounds"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rondas"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rondas"
}
},
"es-US" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rondas"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Parties"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Parties"
}
}
}
},
"Rounds Played" : {
"comment" : "A label displayed next to the number of rounds played in the game over screen.",
"localizations" : {
@ -1096,6 +1376,46 @@
}
}
},
"Statistics" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Statistics"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Estadísticas"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Estadísticas"
}
},
"es-US" : {
"stringUnit" : {
"state" : "translated",
"value" : "Estadísticas"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Statistiques"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Statistiques"
}
}
}
},
"tableLimitsFormat" : {
"comment" : "Format string for table limits display. First argument is min bet, second is max bet.",
"extractionState" : "stale",
@ -1140,6 +1460,46 @@
},
"Third Card Rules" : {
},
"Tie" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tie"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Empate"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Empate"
}
},
"es-US" : {
"stringUnit" : {
"state" : "translated",
"value" : "Empate"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Égalité"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Égalité"
}
}
}
},
"TIE" : {
"comment" : "The text displayed in the TIE betting zone.",
@ -1226,6 +1586,10 @@
"comment" : "A label displayed next to the total winnings in the result banner.",
"isCommentAutoGenerated" : true
},
"View detailed game statistics" : {
"comment" : "A hint that appears when hovering over the \"Statistics\" button, explaining its function.",
"isCommentAutoGenerated" : true
},
"WIN" : {
"comment" : "The text that appears as a badge when a player wins a hand in baccarat.",
"localizations" : {

View File

@ -15,6 +15,7 @@ struct GameTableView: View {
@State private var selectedChip: ChipDenomination = .hundred
@State private var showSettings = false
@State private var showRules = false
@State private var showStats = false
private var state: GameState {
gameState ?? GameState(settings: settings)
@ -46,7 +47,8 @@ struct GameTableView: View {
showCardsRemaining: settings.showCardsRemaining,
onReset: { state.resetGame() },
onSettings: { showSettings = true },
onHelp: { showRules = true }
onHelp: { showRules = true },
onStats: { showStats = true }
)
Spacer(minLength: Design.Spacing.xSmall)
@ -149,6 +151,9 @@ struct GameTableView: View {
.fullScreenCover(isPresented: $showRules) {
RulesHelpView()
}
.sheet(isPresented: $showStats) {
StatisticsSheetView(results: state.roundHistory)
}
}
}
@ -541,6 +546,7 @@ struct TopBarView: View {
let onReset: () -> Void
let onSettings: () -> Void
let onHelp: () -> Void
let onStats: () -> Void
// MARK: - Environment
@ -605,6 +611,18 @@ struct TopBarView: View {
Spacer()
}
// Statistics button
Button("Statistics", systemImage: "chart.bar.fill", action: onStats)
.labelStyle(.iconOnly)
.font(.system(size: buttonFontSize))
.foregroundStyle(.white.opacity(Design.Opacity.accent))
.padding(Design.Spacing.small)
.background(
Circle()
.fill(Color.black.opacity(Design.Opacity.overlay))
)
.accessibilityHint(String(localized: "View detailed game statistics"))
// Help/Rules button
Button("Help", systemImage: "info.circle.fill", action: onHelp)
.labelStyle(.iconOnly)

View File

@ -42,7 +42,12 @@ struct RoadMapView: View {
ScrollView(.horizontal) {
HStack(spacing: Design.Spacing.xSmall) {
ForEach(results) { result in
RoadDot(result: result.result, dotSize: dotSize)
RoadDot(
result: result.result,
dotSize: dotSize,
hasPair: result.hasPair,
isNatural: result.isNatural
)
}
}
.padding(.vertical, Design.Spacing.xSmall)
@ -65,6 +70,12 @@ struct RoadMapView: View {
struct RoadDot: View {
let result: GameResult
let dotSize: CGFloat
var hasPair: Bool = false
var isNatural: Bool = false
// MARK: - Layout Constants
private var markerSize: CGFloat { dotSize * 0.3 }
// MARK: - Scaled Fonts (Dynamic Type)
@ -99,6 +110,26 @@ struct RoadDot: View {
Text(label)
.font(.system(size: labelFontSize, weight: .bold))
.foregroundStyle(.white)
// Pair marker (bottom-left corner)
if hasPair {
Circle()
.fill(Color.yellow)
.frame(width: markerSize, height: markerSize)
.overlay(
Circle()
.strokeBorder(Color.white, lineWidth: Design.LineWidth.thin)
)
.offset(x: -dotSize * 0.35, y: dotSize * 0.35)
}
// Natural marker (top-right corner - star shape)
if isNatural {
Image(systemName: "star.fill")
.font(.system(size: markerSize))
.foregroundStyle(.yellow)
.offset(x: dotSize * 0.35, y: -dotSize * 0.35)
}
}
}
}
@ -109,11 +140,11 @@ struct RoadDot: View {
.ignoresSafeArea()
RoadMapView(results: [
RoundResult(result: .playerWins, playerValue: 8, bankerValue: 6),
RoundResult(result: .playerWins, playerValue: 8, bankerValue: 6, playerPair: true),
RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 7),
RoundResult(result: .tie, playerValue: 5, bankerValue: 5),
RoundResult(result: .tie, playerValue: 5, bankerValue: 5, bankerPair: true),
RoundResult(result: .playerWins, playerValue: 9, bankerValue: 3),
RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 8)
RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 8, playerPair: true, bankerPair: true)
])
.padding()
}

View File

@ -42,8 +42,8 @@ struct RulesHelpView: View {
var body: some View {
ZStack {
// Background
Color.black.opacity(Design.Opacity.almostFull)
// Background - same as other sheets
Color.Settings.background
.ignoresSafeArea()
VStack(spacing: Design.Spacing.medium) {
@ -107,7 +107,7 @@ struct RulesHelpView: View {
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: contentCornerRadius)
.fill(Color(red: 0.15, green: 0.35, blue: 0.55))
.fill(Color.white.opacity(Design.Opacity.verySubtle))
)
.clipShape(RoundedRectangle(cornerRadius: contentCornerRadius))
}

View File

@ -17,158 +17,102 @@ struct SettingsView: View {
@State private var hasChanges = false
var body: some View {
NavigationStack {
ZStack {
// Background
Color.Settings.background
.ignoresSafeArea()
ScrollView {
VStack(spacing: Design.Spacing.xxLarge) {
// Table Limits Section (First!)
SettingsSection(title: "TABLE LIMITS", icon: "banknote") {
TableLimitsPicker(selection: $settings.tableLimits)
.onChange(of: settings.tableLimits) { _, _ in
hasChanges = true
}
}
// Deck Settings Section
SettingsSection(title: "DECK SETTINGS", icon: "rectangle.portrait.on.rectangle.portrait") {
DeckCountPicker(selection: $settings.deckCount)
.onChange(of: settings.deckCount) { _, _ in
hasChanges = true
}
}
// Starting Balance Section
SettingsSection(title: "STARTING BALANCE", icon: "dollarsign.circle") {
BalancePicker(balance: $settings.startingBalance)
.onChange(of: settings.startingBalance) { _, _ in
hasChanges = true
}
}
// Display Settings Section
SettingsSection(title: "DISPLAY", icon: "eye") {
SettingsToggle(
title: "Show Cards Remaining",
subtitle: "Display deck counter at top",
isOn: $settings.showCardsRemaining
)
Divider()
.background(Color.white.opacity(Design.Opacity.subtle))
SettingsToggle(
title: "Show History",
subtitle: "Display result road map",
isOn: $settings.showHistory
)
}
// Animation Settings Section
SettingsSection(title: "ANIMATIONS", icon: "sparkles") {
SettingsToggle(
title: "Card Animations",
subtitle: "Animate dealing and flipping",
isOn: $settings.showAnimations
)
if settings.showAnimations {
Divider()
.background(Color.white.opacity(Design.Opacity.subtle))
SpeedPicker(speed: $settings.dealingSpeed)
}
}
// Reset Button
Button {
settings.resetToDefaults()
SheetContainerView(
title: String(localized: "Settings"),
content: {
// Table Limits Section (First!)
SheetSection(title: "TABLE LIMITS", icon: "banknote") {
TableLimitsPicker(selection: $settings.tableLimits)
.onChange(of: settings.tableLimits) { _, _ in
hasChanges = true
} label: {
HStack {
Image(systemName: "arrow.counterclockwise")
Text("Reset to Defaults")
}
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.red.opacity(Design.Opacity.heavy))
.padding()
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(Color.red.opacity(Design.Opacity.subtle))
)
}
.padding(.horizontal)
.padding(.top, Design.Spacing.small)
}
.padding(.vertical)
}
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(Color.Settings.background, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
settings.load() // Revert changes
dismiss()
}
.foregroundStyle(.white.opacity(Design.Opacity.strong))
}
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
settings.save()
if hasChanges {
onApplyChanges()
// Deck Settings Section
SheetSection(title: "DECK SETTINGS", icon: "rectangle.portrait.on.rectangle.portrait") {
DeckCountPicker(selection: $settings.deckCount)
.onChange(of: settings.deckCount) { _, _ in
hasChanges = true
}
dismiss()
}
.bold()
.foregroundStyle(.yellow)
}
}
}
}
}
/// A settings section with a title and content.
struct SettingsSection<Content: View>: View {
let title: String
let icon: String
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
// Header
HStack(spacing: Design.Spacing.small) {
Image(systemName: icon)
.font(.system(size: Design.BaseFontSize.body, weight: .semibold))
.foregroundStyle(.yellow.opacity(Design.Opacity.heavy))
Text(title)
.font(.system(size: Design.BaseFontSize.body, weight: .bold, design: .rounded))
.tracking(1)
.foregroundStyle(.white.opacity(Design.Opacity.accent))
}
.padding(.horizontal, Design.Spacing.xSmall)
// Content card
VStack(spacing: 0) {
content
}
.padding()
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(Color.white.opacity(Design.Opacity.verySubtle))
)
}
.padding(.horizontal)
// Starting Balance Section
SheetSection(title: "STARTING BALANCE", icon: "dollarsign.circle") {
BalancePicker(balance: $settings.startingBalance)
.onChange(of: settings.startingBalance) { _, _ in
hasChanges = true
}
}
// Display Settings Section
SheetSection(title: "DISPLAY", icon: "eye") {
SettingsToggle(
title: "Show Cards Remaining",
subtitle: "Display deck counter at top",
isOn: $settings.showCardsRemaining
)
Divider()
.background(Color.white.opacity(Design.Opacity.subtle))
SettingsToggle(
title: "Show History",
subtitle: "Display result road map",
isOn: $settings.showHistory
)
}
// Animation Settings Section
SheetSection(title: "ANIMATIONS", icon: "sparkles") {
SettingsToggle(
title: "Card Animations",
subtitle: "Animate dealing and flipping",
isOn: $settings.showAnimations
)
if settings.showAnimations {
Divider()
.background(Color.white.opacity(Design.Opacity.subtle))
SpeedPicker(speed: $settings.dealingSpeed)
}
}
// Reset Button
Button {
settings.resetToDefaults()
hasChanges = true
} label: {
HStack {
Image(systemName: "arrow.counterclockwise")
Text("Reset to Defaults")
}
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.red.opacity(Design.Opacity.heavy))
.padding()
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(Color.red.opacity(Design.Opacity.subtle))
)
}
.padding(.horizontal)
.padding(.top, Design.Spacing.small)
},
onCancel: {
settings.load() // Revert changes
dismiss()
},
onDone: {
settings.save()
if hasChanges {
onApplyChanges()
}
dismiss()
},
doneButtonText: String(localized: "Done"),
cancelButtonText: String(localized: "Cancel")
)
}
}

View File

@ -0,0 +1,441 @@
//
// StatisticsSheetView.swift
// Baccarat
//
// Detailed statistics and scoreboard view.
//
import SwiftUI
import CasinoKit
/// A sheet that displays detailed game statistics and Big Road scoreboard.
struct StatisticsSheetView: View {
let results: [RoundResult]
@Environment(\.dismiss) private var dismiss
// MARK: - Computed Statistics
private var totalRounds: Int { results.count }
private var playerWins: Int {
results.filter { $0.result == .playerWins }.count
}
private var bankerWins: Int {
results.filter { $0.result == .bankerWins }.count
}
private var tieCount: Int {
results.filter { $0.result == .tie }.count
}
private var playerPairs: Int {
results.filter { $0.playerPair }.count
}
private var bankerPairs: Int {
results.filter { $0.bankerPair }.count
}
private var naturals: Int {
results.filter { $0.isNatural }.count
}
private func percentage(_ count: Int) -> String {
guard totalRounds > 0 else { return "0%" }
let pct = Double(count) / Double(totalRounds) * 100
return String(format: "%.0f%%", pct)
}
var body: some View {
SheetContainerView(
title: String(localized: "Statistics"),
content: {
// Summary stats
summarySection
// Win distribution
winDistributionSection
// Side bet frequency
sideBetSection
// Big Road display
bigRoadSection
},
onDone: {
dismiss()
},
doneButtonText: String(localized: "Done")
)
}
// MARK: - Summary Section
private var summarySection: some View {
SheetSection(title: "SESSION SUMMARY", icon: "chart.pie.fill") {
HStack(spacing: Design.Spacing.xLarge) {
StatBox(
value: "\(totalRounds)",
label: String(localized: "Rounds"),
color: .white
)
StatBox(
value: "\(naturals)",
label: String(localized: "Naturals"),
color: .yellow
)
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Win Distribution Section
private var winDistributionSection: some View {
SheetSection(title: "WIN DISTRIBUTION", icon: "trophy.fill") {
VStack(spacing: Design.Spacing.medium) {
HStack(spacing: Design.Spacing.medium) {
WinStatView(
title: String(localized: "Player"),
count: playerWins,
percentage: percentage(playerWins),
color: .blue
)
WinStatView(
title: String(localized: "Tie"),
count: tieCount,
percentage: percentage(tieCount),
color: .green
)
WinStatView(
title: String(localized: "Banker"),
count: bankerWins,
percentage: percentage(bankerWins),
color: .red
)
}
// Win bar visualization
if totalRounds > 0 {
WinDistributionBar(
playerWins: playerWins,
tieCount: tieCount,
bankerWins: bankerWins
)
.frame(height: Design.Spacing.large)
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
}
}
}
}
// MARK: - Side Bet Section
private var sideBetSection: some View {
SheetSection(title: "SIDE BET FREQUENCY", icon: "sparkles") {
HStack(spacing: Design.Spacing.xLarge) {
PairStatView(
title: String(localized: "P Pair"),
count: playerPairs,
percentage: percentage(playerPairs),
color: .blue
)
PairStatView(
title: String(localized: "B Pair"),
count: bankerPairs,
percentage: percentage(bankerPairs),
color: .red
)
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Big Road Section
private var bigRoadSection: some View {
SheetSection(title: "BIG ROAD", icon: "chart.bar.xaxis") {
if results.isEmpty {
Text(String(localized: "No rounds played yet"))
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.secondary))
.frame(maxWidth: .infinity)
.padding(.vertical, Design.Spacing.xLarge)
} else {
BigRoadView(results: results)
.frame(height: Design.Size.bigRoadHeight)
}
}
}
}
// MARK: - Supporting Views
/// A box displaying a single statistic.
private struct StatBox: View {
let value: String
let label: String
let color: Color
var body: some View {
VStack(spacing: Design.Spacing.xSmall) {
Text(value)
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
.foregroundStyle(color)
Text(label)
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.secondary))
}
.frame(minWidth: Design.Size.statBoxMinWidth)
.padding(Design.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.black.opacity(Design.Opacity.overlay))
)
}
}
/// A win stat display with count and percentage.
private struct WinStatView: View {
let title: String
let count: Int
let percentage: String
let color: Color
var body: some View {
VStack(spacing: Design.Spacing.xSmall) {
Circle()
.fill(color)
.frame(width: Design.Size.winIndicatorSize, height: Design.Size.winIndicatorSize)
Text("\(count)")
.font(.system(size: Design.BaseFontSize.title, weight: .bold))
.foregroundStyle(.white)
Text(percentage)
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.secondary))
Text(title)
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(color)
}
.frame(maxWidth: .infinity)
}
}
/// A pair stat display.
private struct PairStatView: View {
let title: String
let count: Int
let percentage: String
let color: Color
var body: some View {
VStack(spacing: Design.Spacing.xSmall) {
Text("\(count)")
.font(.system(size: Design.BaseFontSize.title, weight: .bold))
.foregroundStyle(color)
Text(percentage)
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.secondary))
Text(title)
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.accent))
}
}
}
/// A horizontal bar showing win distribution.
private struct WinDistributionBar: View {
let playerWins: Int
let tieCount: Int
let bankerWins: Int
private var total: Int { playerWins + tieCount + bankerWins }
var body: some View {
GeometryReader { geometry in
HStack(spacing: 0) {
if playerWins > 0 {
Rectangle()
.fill(Color.blue)
.frame(width: geometry.size.width * CGFloat(playerWins) / CGFloat(total))
}
if tieCount > 0 {
Rectangle()
.fill(Color.green)
.frame(width: geometry.size.width * CGFloat(tieCount) / CGFloat(total))
}
if bankerWins > 0 {
Rectangle()
.fill(Color.red)
.frame(width: geometry.size.width * CGFloat(bankerWins) / CGFloat(total))
}
}
}
}
}
/// The Big Road scoreboard - a grid showing result patterns.
/// Results are arranged in columns, with each column representing a streak of same results.
private struct BigRoadView: View {
let results: [RoundResult]
private let maxRows = 6
private let cellSize: CGFloat = Design.Size.bigRoadCellSize
/// Convert results into columns for Big Road display.
private var columns: [[RoundResult]] {
var cols: [[RoundResult]] = []
var currentCol: [RoundResult] = []
var lastResult: GameResult?
for result in results {
// Skip ties for column tracking (ties go in the current column)
let currentResult = result.result
if currentResult == .tie {
// Ties don't start new columns, they go with the current streak
if !currentCol.isEmpty {
currentCol.append(result)
} else if !cols.isEmpty {
cols[cols.count - 1].append(result)
} else {
currentCol.append(result)
}
} else if lastResult == nil || currentResult == lastResult {
// Same as last or first result - continue column
currentCol.append(result)
lastResult = currentResult
} else {
// Different result - start new column
if !currentCol.isEmpty {
cols.append(currentCol)
}
currentCol = [result]
lastResult = currentResult
}
}
// Add remaining column
if !currentCol.isEmpty {
cols.append(currentCol)
}
return cols
}
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: Design.Spacing.xxSmall) {
ForEach(Array(columns.enumerated()), id: \.offset) { _, column in
VStack(spacing: Design.Spacing.xxSmall) {
ForEach(Array(column.prefix(maxRows).enumerated()), id: \.offset) { _, result in
BigRoadCell(result: result)
}
// If column has more than maxRows, show overflow count
if column.count > maxRows {
Text("+\(column.count - maxRows)")
.font(.system(size: Design.BaseFontSize.xxSmall))
.foregroundStyle(.white.opacity(Design.Opacity.secondary))
}
Spacer(minLength: 0)
}
}
}
.padding(Design.Spacing.small)
}
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.black.opacity(Design.Opacity.overlay))
)
.scrollIndicators(.hidden)
}
}
/// A single cell in the Big Road display.
private struct BigRoadCell: View {
let result: RoundResult
private let cellSize: CGFloat = Design.Size.bigRoadCellSize
private var color: Color {
switch result.result {
case .playerWins: return .blue
case .bankerWins: return .red
case .tie: return .green
}
}
var body: some View {
ZStack {
// Main circle
Circle()
.stroke(color, lineWidth: Design.LineWidth.medium)
.frame(width: cellSize, height: cellSize)
// Pair indicator (small dot at bottom)
if result.hasPair {
Circle()
.fill(Color.yellow)
.frame(width: cellSize * 0.25, height: cellSize * 0.25)
.offset(y: cellSize * 0.3)
}
// Natural indicator (small dot at top)
if result.isNatural {
Circle()
.fill(Color.white)
.frame(width: cellSize * 0.25, height: cellSize * 0.25)
.offset(y: -cellSize * 0.3)
}
// Tie diagonal line if it's a tie
if result.result == .tie {
Rectangle()
.fill(color)
.frame(width: cellSize * 0.8, height: Design.LineWidth.medium)
.rotationEffect(.degrees(-45))
}
}
}
}
// MARK: - Design Constants Extensions
extension Design.Size {
static let bigRoadHeight: CGFloat = 200
static let bigRoadCellSize: CGFloat = 24
static let statBoxMinWidth: CGFloat = 80
static let winIndicatorSize: CGFloat = 24
}
// MARK: - Preview
#Preview {
StatisticsSheetView(results: [
RoundResult(result: .playerWins, playerValue: 8, bankerValue: 6, playerPair: true),
RoundResult(result: .playerWins, playerValue: 7, bankerValue: 5),
RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 7),
RoundResult(result: .bankerWins, playerValue: 3, bankerValue: 8, bankerPair: true),
RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 6),
RoundResult(result: .tie, playerValue: 5, bankerValue: 5),
RoundResult(result: .playerWins, playerValue: 9, bankerValue: 3),
RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 8, playerPair: true, bankerPair: true)
])
}

View File

@ -19,10 +19,12 @@
// - ChipView, ChipEdgePattern
// - ChipSelectorView
// - ChipStackView, ChipOnTableView
// - SheetContainerView, SheetSection
// MARK: - Theme
// - CasinoTheme (protocol)
// - DefaultCasinoTheme
// - ChipColorSet
// - CasinoDesign (constants)
// - Color.Sheet (sheet colors)

View File

@ -332,8 +332,26 @@
}
},
"MAX" : {
"comment" : "A label indicating the maximum bet.",
"isCommentAutoGenerated" : true
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "MAX"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "MÁX"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "MAX"
}
}
}
},
"maximum bet" : {
"localizations" : {

View File

@ -132,5 +132,35 @@ public enum CasinoDesign {
public static let comfortable: CGFloat = 0.6
public static let relaxed: CGFloat = 0.7
}
// MARK: - Max Chip Stack
public enum ChipStack {
public static let maxChipsToShow: Int = 5
}
}
// MARK: - CasinoKit Colors
/// Shared colors for casino game UI components.
public extension Color {
/// Sheet and popup colors.
enum Sheet {
/// Dark background for sheets and popups.
public static let background = Color(red: 0.08, green: 0.12, blue: 0.18)
/// Subtle fill for section cards.
public static let sectionFill = Color.white.opacity(CasinoDesign.Opacity.subtle)
/// Accent color for buttons and highlights.
public static let accent = Color.yellow
/// Secondary text color.
public static let secondaryText = Color.white.opacity(CasinoDesign.Opacity.accent)
/// Cancel button color.
public static let cancelText = Color.white.opacity(CasinoDesign.Opacity.strong)
}
}

View File

@ -0,0 +1,197 @@
//
// SheetContainerView.swift
// CasinoKit
//
// A reusable container view for modal sheets with consistent styling.
//
import SwiftUI
/// A reusable container for modal sheets providing consistent casino-themed styling.
///
/// Usage:
/// ```swift
/// SheetContainerView(title: "Settings") {
/// // Your content here
/// } onDone: {
/// dismiss()
/// }
/// ```
///
/// With cancel button:
/// ```swift
/// SheetContainerView(
/// title: "Settings",
/// content: { ... },
/// onCancel: { dismiss() },
/// onDone: { save(); dismiss() }
/// )
/// ```
public struct SheetContainerView<Content: View>: View {
let title: String
@ViewBuilder let content: Content
/// Optional cancel action (shows Cancel button on left if provided)
var onCancel: (() -> Void)?
/// Done action (always shown on right)
let onDone: () -> Void
/// Done button text (defaults to "Done")
var doneButtonText: String
/// Cancel button text (defaults to "Cancel")
var cancelButtonText: String
/// Creates a sheet container with the specified configuration.
/// - Parameters:
/// - title: The navigation title for the sheet.
/// - content: The content to display inside the sheet.
/// - onCancel: Optional cancel action. If provided, shows a Cancel button.
/// - onDone: The action to perform when Done is tapped.
/// - doneButtonText: Custom text for the Done button.
/// - cancelButtonText: Custom text for the Cancel button.
public init(
title: String,
@ViewBuilder content: () -> Content,
onCancel: (() -> Void)? = nil,
onDone: @escaping () -> Void,
doneButtonText: String = "Done",
cancelButtonText: String = "Cancel"
) {
self.title = title
self.content = content()
self.onCancel = onCancel
self.onDone = onDone
self.doneButtonText = doneButtonText
self.cancelButtonText = cancelButtonText
}
public var body: some View {
NavigationStack {
ZStack {
// Consistent background
Color.Sheet.background
.ignoresSafeArea()
ScrollView {
VStack(spacing: CasinoDesign.Spacing.xxLarge) {
content
}
.padding(.vertical)
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(Color.Sheet.background, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbar {
if let onCancel {
ToolbarItem(placement: .topBarLeading) {
Button(cancelButtonText) {
onCancel()
}
.foregroundStyle(Color.Sheet.cancelText)
}
}
ToolbarItem(placement: .topBarTrailing) {
Button(doneButtonText) {
onDone()
}
.bold()
.foregroundStyle(Color.Sheet.accent)
}
}
}
}
}
/// A styled section for use within sheets with icon + title header.
///
/// Usage:
/// ```swift
/// SheetSection(title: "SETTINGS", icon: "gearshape") {
/// Toggle("Enable Feature", isOn: $isEnabled)
/// }
/// ```
public struct SheetSection<Content: View>: View {
let title: String
let icon: String
@ViewBuilder let content: Content
/// Creates a sheet section with an icon and title header.
/// - Parameters:
/// - title: The section title (displayed uppercase).
/// - icon: The SF Symbol name for the icon.
/// - content: The content to display inside the section card.
public init(
title: String,
icon: String,
@ViewBuilder content: () -> Content
) {
self.title = title
self.icon = icon
self.content = content()
}
public var body: some View {
VStack(alignment: .leading, spacing: CasinoDesign.Spacing.medium) {
// Header with icon
HStack(spacing: CasinoDesign.Spacing.small) {
Image(systemName: icon)
.font(.system(size: CasinoDesign.BaseFontSize.small, weight: .semibold))
.foregroundStyle(Color.Sheet.accent.opacity(CasinoDesign.Opacity.heavy))
Text(title)
.font(.system(size: CasinoDesign.BaseFontSize.small, weight: .bold, design: .rounded))
.tracking(1)
.foregroundStyle(Color.Sheet.secondaryText)
}
.padding(.horizontal, CasinoDesign.Spacing.xSmall)
// Content card
VStack(spacing: 0) {
content
}
.padding()
.background(
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.large)
.fill(Color.Sheet.sectionFill)
)
}
.padding(.horizontal)
}
}
// MARK: - Preview
#Preview("Sheet with Cancel") {
SheetContainerView(
title: "Example Sheet",
content: {
SheetSection(title: "SECTION ONE", icon: "star.fill") {
Text("Content here")
.foregroundStyle(.white)
}
SheetSection(title: "SECTION TWO", icon: "heart.fill") {
Text("More content")
.foregroundStyle(.white)
}
},
onCancel: { },
onDone: { }
)
}
#Preview("Sheet without Cancel") {
SheetContainerView(title: "Statistics") {
SheetSection(title: "SUMMARY", icon: "chart.pie.fill") {
Text("Stats here")
.foregroundStyle(.white)
}
} onDone: { }
}