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

This commit is contained in:
Matt Bruce 2025-12-22 11:32:30 -06:00
parent 50f569d137
commit ec484d8574
19 changed files with 753 additions and 491 deletions

View File

@ -0,0 +1,108 @@
//
// ActionButton.swift
// Blackjack
//
// Reusable styled button for game actions.
//
import SwiftUI
import CasinoKit
struct ActionButton: View {
let title: String
let icon: String?
let style: ButtonStyle
let action: () -> Void
enum ButtonStyle {
case primary // Gold gradient (Deal, New Round)
case destructive // Red (Clear)
case secondary // Subtle white
case custom(Color) // Game-specific colors (Hit, Stand, etc.)
var foregroundColor: Color {
switch self {
case .primary: return .black
case .destructive, .secondary, .custom: return .white
}
}
}
init(_ title: String, icon: String? = nil, style: ButtonStyle = .primary, action: @escaping () -> Void) {
self.title = title
self.icon = icon
self.style = style
self.action = action
}
var body: some View {
Button(action: action) {
HStack(spacing: Design.Spacing.small) {
if let icon = icon {
Image(systemName: icon)
}
Text(title)
.lineLimit(1)
.minimumScaleFactor(CasinoDesign.MinScaleFactor.relaxed)
}
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
.foregroundStyle(style.foregroundColor)
.padding(.horizontal, Design.Spacing.large)
.padding(.vertical, Design.Spacing.medium)
.background(backgroundView)
}
.accessibilityLabel(title)
}
@ViewBuilder
private var backgroundView: some View {
switch style {
case .primary:
Capsule()
.fill(
LinearGradient(
colors: [Color.Button.goldLight, Color.Button.goldDark],
startPoint: .top,
endPoint: .bottom
)
)
case .destructive:
Capsule()
.fill(Color.red.opacity(Design.Opacity.heavy))
case .secondary:
Capsule()
.fill(Color.white.opacity(Design.Opacity.hint))
case .custom(let color):
Capsule()
.fill(color)
}
}
}
// MARK: - Previews
#Preview("Primary") {
ZStack {
Color.Table.felt.ignoresSafeArea()
ActionButton("Deal", icon: "play.fill", style: .primary) {}
}
}
#Preview("Destructive") {
ZStack {
Color.Table.felt.ignoresSafeArea()
ActionButton("Clear", icon: "xmark.circle", style: .destructive) {}
}
}
#Preview("Custom Colors") {
ZStack {
Color.Table.felt.ignoresSafeArea()
HStack(spacing: Design.Spacing.medium) {
ActionButton("Hit", style: .custom(Color.Button.hit)) {}
ActionButton("Stand", style: .custom(Color.Button.stand)) {}
ActionButton("Double", style: .custom(Color.Button.doubleDown)) {}
}
}
}

View File

@ -0,0 +1,314 @@
//
// ActionButtonsView.swift
// Blackjack
//
// Container for game action buttons (betting, player turn).
//
import SwiftUI
import CasinoKit
struct ActionButtonsView: View {
@Bindable var state: GameState
// Scaled metrics
@ScaledMetric(relativeTo: .headline) private var buttonFontSize: CGFloat = Design.BaseFontSize.large
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = Design.IconSize.large
// Fixed height to prevent layout shifts
private let containerHeight: CGFloat = 120
var body: some View {
ZStack {
Color.clear
.frame(height: containerHeight)
VStack(spacing: Design.Spacing.medium) {
// Primary actions
HStack(spacing: Design.Spacing.medium) {
switch state.currentPhase {
case .betting:
bettingButtons
case .playerTurn:
playerTurnButtons
case .roundComplete:
// Empty - handled by result banner
EmptyView()
default:
// Dealing, dealer turn - show nothing
EmptyView()
}
}
.animation(.spring(duration: Design.Animation.quick), value: state.currentPhase)
}
}
.padding(.horizontal, Design.Spacing.large)
}
// MARK: - Betting Phase Buttons
/// Whether the current bet meets the minimum requirement
private var isBetBelowMinimum: Bool {
state.currentBet > 0 && state.currentBet < state.settings.minBet
}
/// Amount needed to reach minimum bet
private var amountNeededForMinimum: Int {
state.settings.minBet - state.currentBet
}
@ViewBuilder
private var bettingButtons: some View {
if state.currentBet > 0 {
VStack(spacing: Design.Spacing.small) {
// Show hint if bet is below minimum
if isBetBelowMinimum {
Text(String(localized: "Add $\(amountNeededForMinimum) more to meet minimum"))
.font(.system(size: Design.BaseFontSize.small, weight: .medium))
.foregroundStyle(.orange)
.transition(.opacity)
}
HStack(spacing: Design.Spacing.medium) {
ActionButton(
String(localized: "Clear"),
icon: "xmark.circle",
style: .destructive
) {
state.clearBet()
}
// Always show Deal button, but disable if below minimum
ActionButton(
String(localized: "Deal"),
icon: "play.fill",
style: .primary
) {
Task { await state.deal() }
}
.opacity(state.canDeal ? 1.0 : Design.Opacity.medium)
.disabled(!state.canDeal)
}
}
}
}
// MARK: - Player Turn Buttons
@ViewBuilder
private var playerTurnButtons: some View {
// All player actions in a single row
HStack(spacing: Design.Spacing.medium) {
if state.canHit {
ActionButton(
String(localized: "Hit"),
style: .custom(Color.Button.hit)
) {
Task { await state.hit() }
}
}
if state.canStand {
ActionButton(
String(localized: "Stand"),
style: .custom(Color.Button.stand)
) {
Task { await state.stand() }
}
}
if state.canDouble {
ActionButton(
String(localized: "Double"),
style: .custom(Color.Button.doubleDown)
) {
Task { await state.doubleDown() }
}
}
if state.canSplit {
ActionButton(
String(localized: "Split"),
style: .custom(Color.Button.split)
) {
Task { await state.split() }
}
}
if state.canSurrender {
ActionButton(
String(localized: "Surrender"),
style: .custom(Color.Button.surrender)
) {
Task { await state.surrender() }
}
}
}
}
}
// MARK: - Previews
#Preview("Betting Phase - No Bet") {
ZStack {
Color.Table.felt.ignoresSafeArea()
ActionButtonsView(state: {
let state = GameState(settings: GameSettings())
return state
}())
}
}
#Preview("Betting Phase - With Bet") {
ZStack {
Color.Table.felt.ignoresSafeArea()
ActionButtonsView(state: {
let state = GameState(settings: GameSettings())
state.placeBet(amount: 100)
return state
}())
}
}
#Preview("Betting Phase - Below Minimum") {
ZStack {
Color.Table.felt.ignoresSafeArea()
ActionButtonsView(state: {
let state = GameState(settings: GameSettings())
state.placeBet(amount: 25)
return state
}())
}
}
// MARK: - Player Turn Button Previews
/// Preview helper that shows player turn button layouts without needing real game state.
private struct PlayerTurnButtonsPreview: View {
let showHit: Bool
let showStand: Bool
let showDouble: Bool
let showSplit: Bool
let showSurrender: Bool
private let containerHeight: CGFloat = 120
var body: some View {
ZStack {
Color.clear
.frame(height: containerHeight)
// Single row of buttons matching actual game layout
HStack(spacing: Design.Spacing.medium) {
if showHit {
ActionButton("Hit", style: .custom(Color.Button.hit)) {}
}
if showStand {
ActionButton("Stand", style: .custom(Color.Button.stand)) {}
}
if showDouble {
ActionButton("Double", style: .custom(Color.Button.doubleDown)) {}
}
if showSplit {
ActionButton("Split", style: .custom(Color.Button.split)) {}
}
if showSurrender {
ActionButton("Surrender", style: .custom(Color.Button.surrender)) {}
}
}
}
.padding(.horizontal, Design.Spacing.large)
}
}
#Preview("Player Turn - Hit & Stand Only") {
ZStack {
Color.Table.felt.ignoresSafeArea()
PlayerTurnButtonsPreview(
showHit: true,
showStand: true,
showDouble: false,
showSplit: false,
showSurrender: false
)
}
}
#Preview("Player Turn - With Double") {
ZStack {
Color.Table.felt.ignoresSafeArea()
PlayerTurnButtonsPreview(
showHit: true,
showStand: true,
showDouble: true,
showSplit: false,
showSurrender: false
)
}
}
#Preview("Player Turn - With Split") {
ZStack {
Color.Table.felt.ignoresSafeArea()
PlayerTurnButtonsPreview(
showHit: true,
showStand: true,
showDouble: true,
showSplit: true,
showSurrender: false
)
}
}
#Preview("Player Turn - With Surrender") {
ZStack {
Color.Table.felt.ignoresSafeArea()
PlayerTurnButtonsPreview(
showHit: true,
showStand: true,
showDouble: true,
showSplit: false,
showSurrender: true
)
}
}
#Preview("Player Turn - All Options") {
ZStack {
Color.Table.felt.ignoresSafeArea()
PlayerTurnButtonsPreview(
showHit: true,
showStand: true,
showDouble: true,
showSplit: true,
showSurrender: true
)
}
}
#Preview("Player Turn - After Hit (No Double/Split)") {
ZStack {
Color.Table.felt.ignoresSafeArea()
PlayerTurnButtonsPreview(
showHit: true,
showStand: true,
showDouble: false,
showSplit: false,
showSurrender: false
)
}
}
#Preview("Player Turn - Split Hand (No Resplit)") {
ZStack {
Color.Table.felt.ignoresSafeArea()
PlayerTurnButtonsPreview(
showHit: true,
showStand: true,
showDouble: true,
showSplit: false,
showSurrender: false
)
}
}

View File

@ -0,0 +1,88 @@
//
// CardCountView.swift
// Blackjack
//
// Displays the Hi-Lo running and true count for card counting practice.
//
import SwiftUI
import CasinoKit
/// Displays the Hi-Lo running count for card counting practice.
struct CardCountView: View {
let runningCount: Int
let trueCount: Double
var body: some View {
HStack(spacing: Design.Spacing.large) {
// Running count
VStack(spacing: Design.Spacing.xxSmall) {
Text("Running")
.font(.system(size: Design.Size.cardCountLabelSize, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Text(runningCount >= 0 ? "+\(runningCount)" : "\(runningCount)")
.font(.system(size: Design.Size.cardCountValueSize, weight: .bold, design: .monospaced))
.foregroundStyle(countColor(for: runningCount))
}
Divider()
.frame(height: Design.Spacing.xLarge)
.background(Color.white.opacity(Design.Opacity.hint))
// True count
VStack(spacing: Design.Spacing.xxSmall) {
Text("True")
.font(.system(size: Design.Size.cardCountLabelSize, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Text(trueCount >= 0 ? "+\(trueCount, format: .number.precision(.fractionLength(1)))" : "\(trueCount, format: .number.precision(.fractionLength(1)))")
.font(.system(size: Design.Size.cardCountValueSize, weight: .bold, design: .monospaced))
.foregroundStyle(countColor(for: Int(trueCount.rounded())))
}
}
.padding(.horizontal, Design.Spacing.large)
.padding(.vertical, Design.Spacing.small)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.black.opacity(Design.Opacity.subtle))
)
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Card Count"))
.accessibilityValue(String(localized: "Running \(runningCount), True \(trueCount, format: .number.precision(.fractionLength(1)))"))
}
private func countColor(for count: Int) -> Color {
if count > 0 {
return .green // Positive count favors player
} else if count < 0 {
return .red // Negative count favors house
} else {
return .white // Neutral
}
}
}
// MARK: - Previews
#Preview("Neutral Count") {
ZStack {
Color.Table.felt.ignoresSafeArea()
CardCountView(runningCount: 0, trueCount: 0.0)
}
}
#Preview("Positive Count") {
ZStack {
Color.Table.felt.ignoresSafeArea()
CardCountView(runningCount: 7, trueCount: 2.3)
}
}
#Preview("Negative Count") {
ZStack {
Color.Table.felt.ignoresSafeArea()
CardCountView(runningCount: -4, trueCount: -1.5)
}
}

View File

@ -0,0 +1,183 @@
//
// GameTableView.swift
// Blackjack
//
// Main game container view.
//
import SwiftUI
import CasinoKit
struct GameTableView: View {
@State private var settings = GameSettings()
@State private var gameState: GameState?
@State private var selectedChip: ChipDenomination = .twentyFive
// MARK: - Sheet State
@State private var showSettings = false
@State private var showRules = false
@State private var showStats = false
// MARK: - Environment
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass
/// Whether we're on iPad
private var isIPad: Bool {
horizontalSizeClass == .regular
}
/// Maximum content width based on device
private var maxContentWidth: CGFloat {
if isIPad {
return verticalSizeClass == .compact
? Design.Size.maxContentWidthLandscape
: Design.Size.maxContentWidthPortrait
}
return .infinity
}
// MARK: - Body
var body: some View {
Group {
if let state = gameState {
mainGameView(state: state)
} else {
ProgressView()
.task {
gameState = GameState(settings: settings)
}
}
}
.sheet(isPresented: $showSettings) {
SettingsView(settings: settings, gameState: gameState)
}
.sheet(isPresented: $showRules) {
RulesHelpView()
}
.sheet(isPresented: $showStats) {
if let state = gameState {
StatisticsSheetView(state: state)
}
}
}
// MARK: - Main Game View
@ViewBuilder
private func mainGameView(state: GameState) -> some View {
ZStack {
// Background
TableBackgroundView()
VStack(spacing: 0) {
// Top bar
TopBarView(
balance: state.balance,
secondaryInfo: settings.showCardsRemaining ? "\(state.engine.cardsRemaining)" : nil,
secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil,
onReset: { state.resetGame() },
onSettings: { showSettings = true },
onHelp: { showRules = true },
onStats: { showStats = true }
)
.frame(maxWidth: maxContentWidth)
// Card count display (when enabled)
if settings.showCardCount {
CardCountView(
runningCount: state.engine.runningCount,
trueCount: state.engine.trueCount
)
.frame(maxWidth: maxContentWidth)
}
// Reshuffle notification
if state.showReshuffleNotification {
ReshuffleNotificationView(showCardCount: settings.showCardCount)
.frame(maxWidth: maxContentWidth)
.transition(.move(edge: .top).combined(with: .opacity))
}
// Table layout
BlackjackTableView(
state: state,
onPlaceBet: { placeBet(state: state) }
)
.frame(maxWidth: maxContentWidth)
Spacer()
// Chip selector - only shown during betting phase
if state.currentPhase == .betting {
ChipSelectorView(
selectedChip: $selectedChip,
balance: state.balance,
currentBet: state.currentBet,
maxBet: state.settings.maxBet
)
.frame(maxWidth: maxContentWidth)
.padding(.bottom, Design.Spacing.small)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
// Action buttons
ActionButtonsView(state: state)
.frame(maxWidth: maxContentWidth)
.padding(.bottom, Design.Spacing.medium)
}
.frame(maxWidth: .infinity)
// Insurance popup overlay (covers entire screen)
if state.currentPhase == .insurance {
InsurancePopupView(
betAmount: state.currentBet / 2,
balance: state.balance,
onTake: { Task { await state.takeInsurance() } },
onDecline: { state.declineInsurance() }
)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
}
// Result banner overlay
if state.showResultBanner, let result = state.lastRoundResult {
ResultBannerView(
result: result,
currentBalance: state.balance,
minBet: state.settings.minBet,
onNewRound: { state.newRound() },
onPlayAgain: { state.resetGame() }
)
}
// Confetti for wins (matching Baccarat pattern)
if state.showResultBanner && (state.lastRoundResult?.totalWinnings ?? 0) > 0 {
ConfettiView()
}
// Game over
if state.isGameOver && !state.showResultBanner {
GameOverView(
roundsPlayed: state.roundsPlayed,
onPlayAgain: { state.resetGame() }
)
}
}
}
// MARK: - Betting
private func placeBet(state: GameState) {
state.placeBet(amount: selectedChip.rawValue)
}
}
// MARK: - Preview
#Preview {
GameTableView()
}

View File

@ -0,0 +1,60 @@
//
// ReshuffleNotificationView.swift
// Blackjack
//
// Shows a notification when the shoe is reshuffled.
//
import SwiftUI
import CasinoKit
/// Shows a notification when the shoe is reshuffled.
struct ReshuffleNotificationView: View {
let showCardCount: Bool
var body: some View {
HStack(spacing: Design.Spacing.small) {
Image(systemName: "shuffle")
.foregroundStyle(.white)
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text("Shoe Reshuffled")
.font(.system(size: Design.BaseFontSize.medium, weight: .bold))
.foregroundStyle(.white)
if showCardCount {
Text("Count reset to 0")
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
}
}
}
.padding(.horizontal, Design.Spacing.large)
.padding(.vertical, Design.Spacing.medium)
.background(
Capsule()
.fill(Color.blue.opacity(Design.Opacity.heavy))
)
.accessibilityElement(children: .ignore)
.accessibilityLabel(showCardCount
? String(localized: "Shoe reshuffled, count reset to zero")
: String(localized: "Shoe reshuffled"))
}
}
// MARK: - Previews
#Preview("Without Count") {
ZStack {
Color.Table.felt.ignoresSafeArea()
ReshuffleNotificationView(showCardCount: false)
}
}
#Preview("With Count Reset") {
ZStack {
Color.Table.felt.ignoresSafeArea()
ReshuffleNotificationView(showCardCount: true)
}
}

View File

@ -1,491 +0,0 @@
//
// GameTableView.swift
// Blackjack
//
// Main game container view.
//
import SwiftUI
import CasinoKit
struct GameTableView: View {
@State private var settings = GameSettings()
@State private var gameState: GameState?
@State private var selectedChip: ChipDenomination = .twentyFive
// MARK: - Sheet State
@State private var showSettings = false
@State private var showRules = false
@State private var showStats = false
// MARK: - Environment
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass
/// Whether we're on iPad
private var isIPad: Bool {
horizontalSizeClass == .regular
}
/// Maximum content width based on device
private var maxContentWidth: CGFloat {
if isIPad {
return verticalSizeClass == .compact
? Design.Size.maxContentWidthLandscape
: Design.Size.maxContentWidthPortrait
}
return .infinity
}
// MARK: - Body
var body: some View {
Group {
if let state = gameState {
mainGameView(state: state)
} else {
ProgressView()
.task {
gameState = GameState(settings: settings)
}
}
}
.sheet(isPresented: $showSettings) {
SettingsView(settings: settings, gameState: gameState)
}
.sheet(isPresented: $showRules) {
RulesHelpView()
}
.sheet(isPresented: $showStats) {
if let state = gameState {
StatisticsSheetView(state: state)
}
}
}
// MARK: - Main Game View
@ViewBuilder
private func mainGameView(state: GameState) -> some View {
ZStack {
// Background
TableBackgroundView()
VStack(spacing: 0) {
// Top bar
TopBarView(
balance: state.balance,
secondaryInfo: settings.showCardsRemaining ? "\(state.engine.cardsRemaining)" : nil,
secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil,
onReset: { state.resetGame() },
onSettings: { showSettings = true },
onHelp: { showRules = true },
onStats: { showStats = true }
)
.frame(maxWidth: maxContentWidth)
// Card count display (when enabled)
if settings.showCardCount {
CardCountView(
runningCount: state.engine.runningCount,
trueCount: state.engine.trueCount
)
.frame(maxWidth: maxContentWidth)
}
// Reshuffle notification
if state.showReshuffleNotification {
ReshuffleNotificationView(showCardCount: settings.showCardCount)
.frame(maxWidth: maxContentWidth)
.transition(.move(edge: .top).combined(with: .opacity))
}
// Table layout
BlackjackTableView(
state: state,
onPlaceBet: { placeBet(state: state) }
)
.frame(maxWidth: maxContentWidth)
Spacer()
// Chip selector - only shown during betting phase
if state.currentPhase == .betting {
ChipSelectorView(
selectedChip: $selectedChip,
balance: state.balance,
currentBet: state.currentBet,
maxBet: state.settings.maxBet
)
.frame(maxWidth: maxContentWidth)
.padding(.bottom, Design.Spacing.small)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
// Action buttons
ActionButtonsView(state: state)
.frame(maxWidth: maxContentWidth)
.padding(.bottom, Design.Spacing.medium)
}
.frame(maxWidth: .infinity)
// Insurance popup overlay (covers entire screen)
if state.currentPhase == .insurance {
InsurancePopupView(
betAmount: state.currentBet / 2,
balance: state.balance,
onTake: { Task { await state.takeInsurance() } },
onDecline: { state.declineInsurance() }
)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
}
// Result banner overlay
if state.showResultBanner, let result = state.lastRoundResult {
ResultBannerView(
result: result,
currentBalance: state.balance,
minBet: state.settings.minBet,
onNewRound: { state.newRound() },
onPlayAgain: { state.resetGame() }
)
}
// Confetti for wins (matching Baccarat pattern)
if state.showResultBanner && (state.lastRoundResult?.totalWinnings ?? 0) > 0 {
ConfettiView()
}
// Game over
if state.isGameOver && !state.showResultBanner {
GameOverView(
roundsPlayed: state.roundsPlayed,
onPlayAgain: { state.resetGame() }
)
}
}
}
// MARK: - Betting
private func placeBet(state: GameState) {
state.placeBet(amount: selectedChip.rawValue)
}
}
// MARK: - Action Buttons View
struct ActionButtonsView: View {
@Bindable var state: GameState
// Scaled metrics
@ScaledMetric(relativeTo: .headline) private var buttonFontSize: CGFloat = Design.BaseFontSize.large
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = Design.IconSize.large
// Fixed height to prevent layout shifts
private let containerHeight: CGFloat = 120
var body: some View {
ZStack {
Color.clear
.frame(height: containerHeight)
VStack(spacing: Design.Spacing.medium) {
// Primary actions
HStack(spacing: Design.Spacing.medium) {
switch state.currentPhase {
case .betting:
bettingButtons
case .playerTurn:
playerTurnButtons
case .roundComplete:
// Empty - handled by result banner
EmptyView()
default:
// Dealing, dealer turn - show nothing
EmptyView()
}
}
.animation(.spring(duration: Design.Animation.quick), value: state.currentPhase)
}
}
.padding(.horizontal, Design.Spacing.large)
}
// MARK: - Betting Phase Buttons
/// Whether the current bet meets the minimum requirement
private var isBetBelowMinimum: Bool {
state.currentBet > 0 && state.currentBet < state.settings.minBet
}
/// Amount needed to reach minimum bet
private var amountNeededForMinimum: Int {
state.settings.minBet - state.currentBet
}
@ViewBuilder
private var bettingButtons: some View {
if state.currentBet > 0 {
VStack(spacing: Design.Spacing.small) {
// Show hint if bet is below minimum
if isBetBelowMinimum {
Text(String(localized: "Add $\(amountNeededForMinimum) more to meet minimum"))
.font(.system(size: Design.BaseFontSize.small, weight: .medium))
.foregroundStyle(.orange)
.transition(.opacity)
}
HStack(spacing: Design.Spacing.medium) {
ActionButton(
String(localized: "Clear"),
icon: "xmark.circle",
style: .destructive
) {
state.clearBet()
}
// Always show Deal button, but disable if below minimum
ActionButton(
String(localized: "Deal"),
icon: "play.fill",
style: .primary
) {
Task { await state.deal() }
}
.opacity(state.canDeal ? 1.0 : Design.Opacity.medium)
.disabled(!state.canDeal)
}
}
}
}
// MARK: - Player Turn Buttons
@ViewBuilder
private var playerTurnButtons: some View {
// Top row: Hit, Stand
HStack(spacing: Design.Spacing.medium) {
if state.canHit {
ActionButton(
String(localized: "Hit"),
style: .custom(Color.Button.hit)
) {
Task { await state.hit() }
}
}
if state.canStand {
ActionButton(
String(localized: "Stand"),
style: .custom(Color.Button.stand)
) {
Task { await state.stand() }
}
}
}
// Bottom row: Double, Split, Surrender
HStack(spacing: Design.Spacing.medium) {
if state.canDouble {
ActionButton(
String(localized: "Double"),
style: .custom(Color.Button.doubleDown)
) {
Task { await state.doubleDown() }
}
}
if state.canSplit {
ActionButton(
String(localized: "Split"),
style: .custom(Color.Button.split)
) {
Task { await state.split() }
}
}
if state.canSurrender {
ActionButton(
String(localized: "Surrender"),
style: .custom(Color.Button.surrender)
) {
Task { await state.surrender() }
}
}
}
}
}
// MARK: - Action Button
struct ActionButton: View {
let title: String
let icon: String?
let style: ButtonStyle
let action: () -> Void
enum ButtonStyle {
case primary // Gold gradient (Deal, New Round)
case destructive // Red (Clear)
case secondary // Subtle white
case custom(Color) // Game-specific colors (Hit, Stand, etc.)
var foregroundColor: Color {
switch self {
case .primary: return .black
case .destructive, .secondary, .custom: return .white
}
}
}
init(_ title: String, icon: String? = nil, style: ButtonStyle = .primary, action: @escaping () -> Void) {
self.title = title
self.icon = icon
self.style = style
self.action = action
}
var body: some View {
Button(action: action) {
HStack(spacing: Design.Spacing.small) {
if let icon = icon {
Image(systemName: icon)
}
Text(title)
}
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
.foregroundStyle(style.foregroundColor)
.padding(.horizontal, Design.Spacing.xxLarge)
.padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall)
.background(backgroundView)
}
.accessibilityLabel(title)
}
@ViewBuilder
private var backgroundView: some View {
switch style {
case .primary:
Capsule()
.fill(
LinearGradient(
colors: [Color.Button.goldLight, Color.Button.goldDark],
startPoint: .top,
endPoint: .bottom
)
)
case .destructive:
Capsule()
.fill(Color.red.opacity(Design.Opacity.heavy))
case .secondary:
Capsule()
.fill(Color.white.opacity(Design.Opacity.hint))
case .custom(let color):
Capsule()
.fill(color)
}
}
}
// MARK: - Card Count View
/// Displays the Hi-Lo running count for card counting practice.
struct CardCountView: View {
let runningCount: Int
let trueCount: Double
var body: some View {
HStack(spacing: Design.Spacing.large) {
// Running count
VStack(spacing: Design.Spacing.xxSmall) {
Text("Running")
.font(.system(size: Design.Size.cardCountLabelSize, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Text(runningCount >= 0 ? "+\(runningCount)" : "\(runningCount)")
.font(.system(size: Design.Size.cardCountValueSize, weight: .bold, design: .monospaced))
.foregroundStyle(countColor(for: runningCount))
}
Divider()
.frame(height: Design.Spacing.xLarge)
.background(Color.white.opacity(Design.Opacity.hint))
// True count
VStack(spacing: Design.Spacing.xxSmall) {
Text("True")
.font(.system(size: Design.Size.cardCountLabelSize, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Text(trueCount >= 0 ? "+\(trueCount, format: .number.precision(.fractionLength(1)))" : "\(trueCount, format: .number.precision(.fractionLength(1)))")
.font(.system(size: Design.Size.cardCountValueSize, weight: .bold, design: .monospaced))
.foregroundStyle(countColor(for: Int(trueCount.rounded())))
}
}
.padding(.horizontal, Design.Spacing.large)
.padding(.vertical, Design.Spacing.small)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.black.opacity(Design.Opacity.subtle))
)
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Card Count"))
.accessibilityValue(String(localized: "Running \(runningCount), True \(trueCount, format: .number.precision(.fractionLength(1)))"))
}
private func countColor(for count: Int) -> Color {
if count > 0 {
return .green // Positive count favors player
} else if count < 0 {
return .red // Negative count favors house
} else {
return .white // Neutral
}
}
}
// MARK: - Reshuffle Notification View
/// Shows a notification when the shoe is reshuffled.
struct ReshuffleNotificationView: View {
let showCardCount: Bool
var body: some View {
HStack(spacing: Design.Spacing.small) {
Image(systemName: "shuffle")
.foregroundStyle(.white)
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text("Shoe Reshuffled")
.font(.system(size: Design.BaseFontSize.medium, weight: .bold))
.foregroundStyle(.white)
if showCardCount {
Text("Count reset to 0")
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
}
}
}
.padding(.horizontal, Design.Spacing.large)
.padding(.vertical, Design.Spacing.medium)
.background(
Capsule()
.fill(Color.blue.opacity(Design.Opacity.heavy))
)
.accessibilityElement(children: .ignore)
.accessibilityLabel(showCardCount
? String(localized: "Shoe reshuffled, count reset to zero")
: String(localized: "Shoe reshuffled"))
}
}
// MARK: - Preview
#Preview {
GameTableView()
}