CasinoGames/Blackjack/Blackjack/Views/Sheets/ResultBannerView.swift

405 lines
15 KiB
Swift

//
// ResultBannerView.swift
// Blackjack
//
// Displays the result of a round with breakdown.
//
import SwiftUI
import CasinoKit
struct ResultBannerView: View {
let result: RoundResult
let currentBalance: Int
let minBet: Int
let onNewRound: () -> Void
let onPlayAgain: () -> Void
@State private var showContent = false
// MARK: - Scaled Metrics
@ScaledMetric(relativeTo: .largeTitle) private var titleFontSize: CGFloat = Design.BaseFontSize.largeTitle
@ScaledMetric(relativeTo: .title) private var resultFontSize: CGFloat = Design.BaseFontSize.title
@ScaledMetric(relativeTo: .headline) private var amountFontSize: CGFloat = Design.BaseFontSize.xLarge
@ScaledMetric(relativeTo: .body) private var buttonFontSize: CGFloat = Design.BaseFontSize.medium
// MARK: - Computed
private var isGameOver: Bool {
currentBalance < minBet
}
private var mainResultColor: Color {
result.mainHandResult.color
}
private var winningsText: String {
if result.totalWinnings > 0 {
return "+$\(result.totalWinnings)"
} else if result.totalWinnings < 0 {
return "-$\(abs(result.totalWinnings))"
} else {
return "$0"
}
}
private var winningsColor: Color {
if result.totalWinnings > 0 { return .green }
if result.totalWinnings < 0 { return .red }
return .blue
}
var body: some View {
ZStack {
// Full screen dark background
Color.black.opacity(Design.Opacity.strong)
// Content card
VStack(spacing: Design.Spacing.xLarge) {
// Main result
Text(result.mainHandResult.displayText)
.font(.system(size: titleFontSize, weight: .black, design: .rounded))
.foregroundStyle(mainResultColor)
// Winnings
Text(winningsText)
.font(.system(size: amountFontSize, weight: .bold, design: .rounded))
.foregroundStyle(winningsColor)
// Breakdown - all hands with amounts for splits
VStack(spacing: Design.Spacing.small) {
ForEach(result.handResults.indices, id: \.self) { index in
let handResult = result.handResults[index]
let handWinnings = index < result.handWinnings.count ? result.handWinnings[index] : nil
// Hand numbering: index 0 = Hand 1 (played first, displayed rightmost)
let handLabel = result.handResults.count > 1
? String(localized: "Hand \(index + 1)")
: String(localized: "Main Hand")
// Show amounts for split hands, or for single hand if there are winnings
let showAmount = result.hadSplit && handWinnings != nil
ResultRow(
label: handLabel,
result: handResult,
amount: showAmount ? handWinnings : nil
)
}
if let insuranceResult = result.insuranceResult {
let showInsAmount = result.insuranceWinnings != 0
ResultRow(
label: String(localized: "Insurance"),
result: insuranceResult,
amount: showInsAmount ? result.insuranceWinnings : nil
)
}
// Side bet results
if let ppResult = result.perfectPairsResult {
SideBetResultRow(
label: String(localized: "Perfect Pairs"),
resultText: ppResult.displayName,
isWin: ppResult.isWin,
amount: result.perfectPairsWinnings
)
}
if let topResult = result.twentyOnePlusThreeResult {
SideBetResultRow(
label: String(localized: "21+3"),
resultText: topResult.displayName,
isWin: topResult.isWin,
amount: result.twentyOnePlusThreeWinnings
)
}
}
.padding(Design.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.white.opacity(Design.Opacity.subtle))
)
// Game over message
if isGameOver {
VStack(spacing: Design.Spacing.small) {
Text(String(localized: "You've run out of chips!"))
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
Button(action: onPlayAgain) {
HStack(spacing: Design.Spacing.small) {
Image(systemName: "arrow.counterclockwise")
Text(String(localized: "Play Again"))
}
.font(.system(size: buttonFontSize, weight: .bold))
.foregroundStyle(.black)
.padding(.horizontal, Design.Spacing.xxLarge)
.padding(.vertical, Design.Spacing.medium)
.background(
Capsule()
.fill(
LinearGradient(
colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark],
startPoint: .top,
endPoint: .bottom
)
)
)
}
}
} else {
// New Round button
Button(action: onNewRound) {
HStack(spacing: Design.Spacing.small) {
Image(systemName: "arrow.clockwise")
Text(String(localized: "New Round"))
}
.font(.system(size: buttonFontSize, weight: .bold))
.foregroundStyle(.black)
.padding(.horizontal, Design.Spacing.xxLarge)
.padding(.vertical, Design.Spacing.medium)
.background(
Capsule()
.fill(
LinearGradient(
colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark],
startPoint: .top,
endPoint: .bottom
)
)
)
}
}
}
.padding(Design.Spacing.xxLarge)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge)
.fill(
LinearGradient(
colors: [Color.CasinoModal.backgroundLight, Color.CasinoModal.backgroundDark],
startPoint: .top,
endPoint: .bottom
)
)
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge)
.strokeBorder(
mainResultColor.opacity(Design.Opacity.medium),
lineWidth: Design.LineWidth.medium
)
)
)
.shadow(color: mainResultColor.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusXLarge)
.frame(maxWidth: CasinoDesign.Size.maxModalWidth)
.padding(.horizontal, Design.Spacing.large) // Prevent clipping on sides
.scaleEffect(showContent ? Design.Scale.normal : Design.Scale.slightShrink)
.opacity(showContent ? 1.0 : 0)
}
.onAppear {
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce)) {
showContent = true
}
// Play game over sound if out of chips (after a delay so it doesn't overlap with result sound)
if isGameOver {
Task {
try? await Task.sleep(for: .seconds(Design.Delay.gameOverSound))
SoundManager.shared.play(.gameOver)
}
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel(String(localized: "Round result: \(result.mainHandResult.displayText)"))
.accessibilityAddTraits(AccessibilityTraits.isModal)
}
}
// MARK: - Result Row
struct ResultRow: View {
let label: String
let result: HandResult
var amount: Int? = nil
private var showDebugBorders: Bool { Design.showDebugBorders }
private var amountText: String? {
guard let amount = amount else { return nil }
if amount > 0 {
return "+$\(amount)"
} else if amount < 0 {
return "-$\(abs(amount))"
} else {
return "$0"
}
}
private var amountColor: Color {
guard let amount = amount else { return .white }
if amount > 0 { return .green }
if amount < 0 { return .red }
return .blue
}
var body: some View {
HStack {
Text(label)
.font(.system(size: Design.BaseFontSize.medium))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
.debugBorder(showDebugBorders, color: .blue, label: "Label")
Spacer()
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer")
// Show amount if provided
if let amountText = amountText {
Text(amountText)
.font(.system(size: Design.BaseFontSize.medium, weight: .semibold, design: .rounded))
.foregroundStyle(amountColor)
.frame(width: Design.Size.resultRowAmountWidth, alignment: .trailing)
.debugBorder(showDebugBorders, color: .green, label: "Amount")
}
Text(result.displayText)
.font(.system(size: Design.BaseFontSize.large, weight: .bold))
.foregroundStyle(result.color)
.frame(width: Design.Size.resultRowResultWidth, alignment: .trailing)
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
.debugBorder(showDebugBorders, color: .red, label: "Result")
}
.debugBorder(showDebugBorders, color: .white, label: "ResultRow")
}
}
// MARK: - Side Bet Result Row
struct SideBetResultRow: View {
let label: String
let resultText: String
let isWin: Bool
let amount: Int
private var showDebugBorders: Bool { Design.showDebugBorders }
private var amountText: String {
if amount > 0 {
return "+$\(amount)"
} else if amount < 0 {
return "-$\(abs(amount))"
} else {
return "$0"
}
}
private var amountColor: Color {
if amount > 0 { return .green }
if amount < 0 { return .red }
return .blue
}
private var resultColor: Color {
isWin ? .green : .red
}
var body: some View {
HStack {
Text(label)
.font(.system(size: Design.BaseFontSize.medium))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
.debugBorder(showDebugBorders, color: .blue, label: "Label")
Spacer()
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer")
Text(amountText)
.font(.system(size: Design.BaseFontSize.medium, weight: .semibold, design: .rounded))
.foregroundStyle(amountColor)
.frame(width: Design.Size.resultRowAmountWidth, alignment: .trailing)
.debugBorder(showDebugBorders, color: .green, label: "Amount")
Text(resultText)
.font(.system(size: Design.BaseFontSize.large, weight: .bold))
.foregroundStyle(resultColor)
.frame(width: Design.Size.resultRowResultWidth, alignment: .trailing)
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
.debugBorder(showDebugBorders, color: .red, label: "Result")
}
.debugBorder(showDebugBorders, color: .white, label: "SideBetRow")
}
}
#Preview("Single Hand") {
ResultBannerView(
result: RoundResult(
handResults: [.blackjack],
handWinnings: [150],
insuranceResult: nil,
insuranceWinnings: 0,
totalWinnings: 150,
wasBlackjack: true
),
currentBalance: 10150,
minBet: 10,
onNewRound: {},
onPlayAgain: {}
)
}
#Preview("Multiple Split Hands") {
ResultBannerView(
result: RoundResult(
handResults: [.bust, .win, .push],
handWinnings: [-100, 100, 0],
insuranceResult: nil,
insuranceWinnings: 0,
totalWinnings: 0,
wasBlackjack: false
),
currentBalance: 1000,
minBet: 10,
onNewRound: {},
onPlayAgain: {}
)
}
#Preview("Split with Insurance") {
ResultBannerView(
result: RoundResult(
handResults: [.lose, .win],
handWinnings: [-100, 200],
insuranceResult: .insuranceWin,
insuranceWinnings: 100,
totalWinnings: 200,
wasBlackjack: false
),
currentBalance: 1200,
minBet: 10,
onNewRound: {},
onPlayAgain: {}
)
}
#Preview("With Side Bets") {
ResultBannerView(
result: RoundResult(
handResults: [.win],
handWinnings: [100],
insuranceResult: nil,
insuranceWinnings: 0,
perfectPairsResult: .coloredPair,
perfectPairsWinnings: 300,
twentyOnePlusThreeResult: .nothing,
twentyOnePlusThreeWinnings: -25,
totalWinnings: 375,
wasBlackjack: false
),
currentBalance: 1375,
minBet: 10,
onNewRound: {},
onPlayAgain: {}
)
}