diff --git a/Blackjack/Views/BrandingPreviewView.swift b/Blackjack/Views/Development/BrandingPreviewView.swift similarity index 100% rename from Blackjack/Views/BrandingPreviewView.swift rename to Blackjack/Views/Development/BrandingPreviewView.swift diff --git a/Blackjack/Views/IconGeneratorView.swift b/Blackjack/Views/Development/IconGeneratorView.swift similarity index 100% rename from Blackjack/Views/IconGeneratorView.swift rename to Blackjack/Views/Development/IconGeneratorView.swift diff --git a/Blackjack/Views/Game/ActionButton.swift b/Blackjack/Views/Game/ActionButton.swift new file mode 100644 index 0000000..f19721f --- /dev/null +++ b/Blackjack/Views/Game/ActionButton.swift @@ -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)) {} + } + } +} + diff --git a/Blackjack/Views/Game/ActionButtonsView.swift b/Blackjack/Views/Game/ActionButtonsView.swift new file mode 100644 index 0000000..6fdbb94 --- /dev/null +++ b/Blackjack/Views/Game/ActionButtonsView.swift @@ -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 + ) + } +} + diff --git a/Blackjack/Views/Game/CardCountView.swift b/Blackjack/Views/Game/CardCountView.swift new file mode 100644 index 0000000..d11280a --- /dev/null +++ b/Blackjack/Views/Game/CardCountView.swift @@ -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) + } +} + diff --git a/Blackjack/Views/Game/GameTableView.swift b/Blackjack/Views/Game/GameTableView.swift new file mode 100644 index 0000000..412d7bc --- /dev/null +++ b/Blackjack/Views/Game/GameTableView.swift @@ -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() +} + diff --git a/Blackjack/Views/Game/ReshuffleNotificationView.swift b/Blackjack/Views/Game/ReshuffleNotificationView.swift new file mode 100644 index 0000000..da5881c --- /dev/null +++ b/Blackjack/Views/Game/ReshuffleNotificationView.swift @@ -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) + } +} + diff --git a/Blackjack/Views/GameTableView.swift b/Blackjack/Views/GameTableView.swift deleted file mode 100644 index 8e6699a..0000000 --- a/Blackjack/Views/GameTableView.swift +++ /dev/null @@ -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() -} - diff --git a/Blackjack/Views/ResultBannerView.swift b/Blackjack/Views/Sheets/ResultBannerView.swift similarity index 100% rename from Blackjack/Views/ResultBannerView.swift rename to Blackjack/Views/Sheets/ResultBannerView.swift diff --git a/Blackjack/Views/RulesHelpView.swift b/Blackjack/Views/Sheets/RulesHelpView.swift similarity index 100% rename from Blackjack/Views/RulesHelpView.swift rename to Blackjack/Views/Sheets/RulesHelpView.swift diff --git a/Blackjack/Views/SettingsView.swift b/Blackjack/Views/Sheets/SettingsView.swift similarity index 100% rename from Blackjack/Views/SettingsView.swift rename to Blackjack/Views/Sheets/SettingsView.swift diff --git a/Blackjack/Views/StatisticsSheetView.swift b/Blackjack/Views/Sheets/StatisticsSheetView.swift similarity index 100% rename from Blackjack/Views/StatisticsSheetView.swift rename to Blackjack/Views/Sheets/StatisticsSheetView.swift diff --git a/Blackjack/Views/BettingZoneView.swift b/Blackjack/Views/Table/BettingZoneView.swift similarity index 100% rename from Blackjack/Views/BettingZoneView.swift rename to Blackjack/Views/Table/BettingZoneView.swift diff --git a/Blackjack/Views/BlackjackTableView.swift b/Blackjack/Views/Table/BlackjackTableView.swift similarity index 100% rename from Blackjack/Views/BlackjackTableView.swift rename to Blackjack/Views/Table/BlackjackTableView.swift diff --git a/Blackjack/Views/DealerHandView.swift b/Blackjack/Views/Table/DealerHandView.swift similarity index 100% rename from Blackjack/Views/DealerHandView.swift rename to Blackjack/Views/Table/DealerHandView.swift diff --git a/Blackjack/Views/HiLoCountBadge.swift b/Blackjack/Views/Table/HiLoCountBadge.swift similarity index 100% rename from Blackjack/Views/HiLoCountBadge.swift rename to Blackjack/Views/Table/HiLoCountBadge.swift diff --git a/Blackjack/Views/HintViews.swift b/Blackjack/Views/Table/HintViews.swift similarity index 100% rename from Blackjack/Views/HintViews.swift rename to Blackjack/Views/Table/HintViews.swift diff --git a/Blackjack/Views/InsurancePopupView.swift b/Blackjack/Views/Table/InsurancePopupView.swift similarity index 100% rename from Blackjack/Views/InsurancePopupView.swift rename to Blackjack/Views/Table/InsurancePopupView.swift diff --git a/Blackjack/Views/PlayerHandView.swift b/Blackjack/Views/Table/PlayerHandView.swift similarity index 100% rename from Blackjack/Views/PlayerHandView.swift rename to Blackjack/Views/Table/PlayerHandView.swift