// // ResultBannerView.swift // Baccarat // // Animated result banner showing the winner and itemized bet results. // import SwiftUI import CasinoKit /// An animated banner showing the round result with bet breakdown. struct ResultBannerView: View { let result: GameResult let totalWinnings: Int let betResults: [BetResult] var playerHadPair: Bool = false var bankerHadPair: Bool = false let currentBalance: Int let minBet: Int let onNewRound: () -> Void let onGameOver: () -> Void /// Whether the player is out of money and can't continue. private var isGameOver: Bool { currentBalance < minBet } @State private var showBanner = false @State private var showText = false @State private var showBreakdown = false @State private var showTotal = false @State private var showButton = false @Environment(\.horizontalSizeClass) private var horizontalSizeClass /// Maximum width for the banner card on iPad private var maxBannerWidth: CGFloat { horizontalSizeClass == .regular ? Design.Size.maxModalWidth : .infinity } // MARK: - Scaled Font Sizes (Dynamic Type) @ScaledMetric(relativeTo: .largeTitle) private var resultFontSize: CGFloat = Design.BaseFontSize.largeTitle @ScaledMetric(relativeTo: .title3) private var totalFontSize: CGFloat = Design.BaseFontSize.xxLarge + Design.Spacing.xSmall @ScaledMetric(relativeTo: .body) private var itemFontSize: CGFloat = Design.BaseFontSize.medium // MARK: - Computed Properties private var winningBets: [BetResult] { betResults.filter { $0.isWin } } private var losingBets: [BetResult] { betResults.filter { $0.isLoss } } private var pushBets: [BetResult] { betResults.filter { $0.isPush } } var body: some View { ZStack { // Background overlay Color.black.opacity(showBanner ? Design.Opacity.medium : 0) .ignoresSafeArea() .animation(.easeIn(duration: Design.Animation.fadeInDuration), value: showBanner) // Banner VStack(spacing: Design.Spacing.medium) { // Result text Text(result.displayText) .font(.system(size: resultFontSize, weight: .black, design: .rounded)) .foregroundStyle( LinearGradient( colors: [.white, result.color], startPoint: .top, endPoint: .bottom ) ) .shadow(color: result.color.opacity(Design.Opacity.heavy), radius: Design.Shadow.radiusLarge) .scaleEffect(showText ? Design.Scale.normal : Design.Scale.shrunk) .opacity(showText ? Design.Scale.normal : 0) // Pair indicators if playerHadPair || bankerHadPair { HStack(spacing: Design.Spacing.large) { if playerHadPair { PairBadge(label: "P PAIR", color: .blue) } if bankerHadPair { PairBadge(label: "B PAIR", color: .red) } } .scaleEffect(showBreakdown ? Design.Scale.normal : Design.Scale.shrunk) .opacity(showBreakdown ? Design.Scale.normal : 0) } // Bet breakdown if !betResults.isEmpty { BetBreakdownView( winningBets: winningBets, losingBets: losingBets, pushBets: pushBets, fontSize: itemFontSize ) .scaleEffect(showBreakdown ? Design.Scale.normal : Design.Scale.shrunk) .opacity(showBreakdown ? Design.Scale.normal : 0) } // Total if totalWinnings != 0 { HStack(spacing: Design.Spacing.small) { Text("TOTAL") .font(.system(size: Design.BaseFontSize.body, weight: .bold)) .foregroundStyle(.white.opacity(Design.Opacity.accent)) if totalWinnings > 0 { Text("+\(totalWinnings)") .font(.system(size: totalFontSize, weight: .black, design: .rounded)) .foregroundStyle(.green) } else { Text("\(totalWinnings)") .font(.system(size: totalFontSize, weight: .black, design: .rounded)) .foregroundStyle(.red) } } .scaleEffect(showTotal ? Design.Scale.normal : Design.Scale.shrunk) .opacity(showTotal ? Design.Scale.normal : 0) } // Game Over message or New Round button if isGameOver { // Game Over - show message and restart button VStack(spacing: Design.Spacing.medium) { Text(String(localized: "You've run out of chips!")) .font(.system(size: Design.BaseFontSize.medium, weight: .medium)) .foregroundStyle(.red.opacity(Design.Opacity.heavy)) Button { onGameOver() } label: { HStack(spacing: Design.Spacing.small) { Image(systemName: "arrow.counterclockwise") Text(String(localized: "Play Again")) } .font(.system(size: Design.BaseFontSize.xLarge, weight: .bold)) .foregroundStyle(.black) .padding(.horizontal, Design.Spacing.xxxLarge + Design.Spacing.small) .padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall) .background( Capsule() .fill( LinearGradient( colors: [Color.Button.goldLight, Color.Button.goldDark], startPoint: .top, endPoint: .bottom ) ) ) .shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium) } } .scaleEffect(showButton ? Design.Scale.normal : Design.Scale.shrunk) .opacity(showButton ? Design.Scale.normal : 0) .padding(.top, Design.Spacing.small) } else { // Normal - New Round button Button { onNewRound() } label: { Text(String(localized: "New Round")) .font(.system(size: Design.BaseFontSize.xLarge, weight: .bold)) .foregroundStyle(.black) .padding(.horizontal, Design.Spacing.xxxLarge + Design.Spacing.small) .padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall) .background( Capsule() .fill( LinearGradient( colors: [Color.Button.goldLight, Color.Button.goldDark], startPoint: .top, endPoint: .bottom ) ) ) .shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium) } .scaleEffect(showButton ? Design.Scale.normal : Design.Scale.shrunk) .opacity(showButton ? Design.Scale.normal : 0) .padding(.top, Design.Spacing.small) } } .padding(.horizontal, Design.Spacing.xLarge) .padding(.vertical, Design.Spacing.xxLarge) .frame(maxWidth: maxBannerWidth) .background( RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge) .fill( LinearGradient( colors: [ Color(white: 0.15), Color(white: 0.08) ], startPoint: .top, endPoint: .bottom ) ) .overlay( RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge) .strokeBorder( LinearGradient( colors: [ result.color.opacity(Design.Opacity.heavy), result.color.opacity(Design.Opacity.light) ], startPoint: .topLeading, endPoint: .bottomTrailing ), lineWidth: Design.LineWidth.thick ) ) ) .shadow(color: result.color.opacity(Design.Opacity.light), radius: Design.Shadow.radiusXXLarge) .padding(.horizontal, Design.Spacing.large) .scaleEffect(showBanner ? Design.Scale.normal : Design.Scale.slightShrink) .opacity(showBanner ? Design.Scale.normal : 0) } .onAppear { withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce)) { showBanner = true } withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce).delay(Design.Animation.staggerDelay1)) { showText = true } withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce).delay(Design.Animation.staggerDelay2)) { showBreakdown = true } withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce).delay(Design.Animation.staggerDelay3)) { showTotal = true } // Show button after everything else withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce).delay(Design.Animation.staggerDelay3 + Design.Animation.staggerDelay1)) { showButton = true } // Play game over sound if out of chips (after a short delay so it doesn't overlap with lose sound) if isGameOver { Task { try? await Task.sleep(for: .seconds(1)) SoundManager.shared.play(.gameOver) } } // Announce result to VoiceOver users announceResult() } .accessibilityElement(children: .ignore) .accessibilityLabel(accessibilityDescription) } // MARK: - Accessibility private var accessibilityDescription: String { var description = result.displayText // Add pair information if playerHadPair { description += ". Player pair" } if bankerHadPair { description += ". Banker pair" } // Add bet results for bet in winningBets { description += ". \(bet.displayName) won \(bet.payout)" } for bet in losingBets { description += ". \(bet.displayName) lost \(abs(bet.payout))" } // Add total if totalWinnings > 0 { description += ". Total winnings: \(totalWinnings)" } else if totalWinnings < 0 { description += ". Total loss: \(abs(totalWinnings))" } return description } private func announceResult() { Task { @MainActor in try? await Task.sleep(for: .milliseconds(500)) AccessibilityNotification.Announcement(accessibilityDescription).post() } } } // MARK: - Bet Breakdown View private struct BetBreakdownView: View { let winningBets: [BetResult] let losingBets: [BetResult] let pushBets: [BetResult] let fontSize: CGFloat var body: some View { VStack(spacing: Design.Spacing.xSmall) { // Winning bets ForEach(winningBets) { bet in BetResultRow(bet: bet, fontSize: fontSize) } // Push bets ForEach(pushBets) { bet in BetResultRow(bet: bet, fontSize: fontSize) } // Losing bets ForEach(losingBets) { bet in BetResultRow(bet: bet, fontSize: fontSize) } } .padding(.horizontal, Design.Spacing.medium) .padding(.vertical, Design.Spacing.small) .background( RoundedRectangle(cornerRadius: Design.CornerRadius.medium) .fill(Color.white.opacity(Design.Opacity.verySubtle)) ) } } // MARK: - Bet Result Row private struct BetResultRow: View { let bet: BetResult let fontSize: CGFloat private var statusColor: Color { if bet.isWin { return .green } if bet.isLoss { return .red } return .yellow // Push } private var statusIcon: String { if bet.isWin { return "checkmark.circle.fill" } if bet.isLoss { return "xmark.circle.fill" } return "arrow.left.arrow.right.circle.fill" // Push } private var payoutText: String { if bet.isWin { return "+\(bet.payout)" } if bet.isLoss { return "\(bet.payout)" } return "PUSH" } var body: some View { HStack(spacing: Design.Spacing.small) { // Status icon Image(systemName: statusIcon) .font(.system(size: fontSize)) .foregroundStyle(statusColor) // Bet name Text(bet.displayName) .font(.system(size: fontSize, weight: .medium)) .foregroundStyle(.white.opacity(Design.Opacity.heavy)) Spacer() // Payout Text(payoutText) .font(.system(size: fontSize, weight: .bold, design: .rounded)) .foregroundStyle(statusColor) } } } // MARK: - Pair Badge private struct PairBadge: View { let label: String let color: Color var body: some View { Text(label) .font(.system(size: Design.BaseFontSize.callout - Design.Spacing.xxSmall, weight: .bold)) .foregroundStyle(.white) .padding(.horizontal, Design.Spacing.small) .padding(.vertical, Design.Spacing.xxSmall) .background( Capsule() .fill(color) ) } } // Note: ConfettiView is now provided by CasinoKit #Preview("Win") { ZStack { Color.Table.preview .ignoresSafeArea() ResultBannerView( result: .playerWins, totalWinnings: 1500, betResults: [ BetResult(type: .player, amount: 1000, payout: 1000), BetResult(type: .playerPair, amount: 100, payout: 1100), BetResult(type: .dragonBonusPlayer, amount: 100, payout: 200), BetResult(type: .tie, amount: 500, payout: -500) ], playerHadPair: true, bankerHadPair: false, currentBalance: 5000, minBet: 10, onNewRound: {}, onGameOver: {} ) } } #Preview("Game Over") { ZStack { Color.Table.preview .ignoresSafeArea() ResultBannerView( result: .bankerWins, totalWinnings: -1000, betResults: [ BetResult(type: .player, amount: 1000, payout: -1000) ], playerHadPair: false, bankerHadPair: false, currentBalance: 0, minBet: 10, onNewRound: {}, onGameOver: {} ) } }