diff --git a/Baccarat/Baccarat/Resources/Localizable.xcstrings b/Baccarat/Baccarat/Resources/Localizable.xcstrings index 549fd4a..f5fb970 100644 --- a/Baccarat/Baccarat/Resources/Localizable.xcstrings +++ b/Baccarat/Baccarat/Resources/Localizable.xcstrings @@ -2606,6 +2606,7 @@ }, "Play Again" : { "comment" : "A button label that says \"Play Again\".", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2855,29 +2856,6 @@ } } }, - "Restore starting balance and reshuffle" : { - "comment" : "Description for the reset game button explaining what it does.", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Restore starting balance and reshuffle" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Restaurar saldo inicial y barajar" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Restaurer le solde initial et mélanger" - } - } - } - }, "Reset to Defaults" : { "comment" : "A button label that resets game settings to their default values.", "localizations" : { @@ -2901,6 +2879,29 @@ } } }, + "Restore starting balance and reshuffle" : { + "comment" : "Description for the reset game button explaining what it does.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restore starting balance and reshuffle" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restaurar saldo inicial y barajar" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restaurer le solde initial et mélanger" + } + } + } + }, "Roulette" : { "comment" : "The name of a roulette game.", "localizations" : { @@ -3542,6 +3543,7 @@ }, "TOTAL" : { "comment" : "A label displayed next to the total winnings in the result banner.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3840,6 +3842,7 @@ }, "You've run out of chips!" : { "comment" : "A message displayed when a player runs out of money in the game over screen.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { diff --git a/Baccarat/Baccarat/Views/Sheets/ResultBannerView.swift b/Baccarat/Baccarat/Views/Sheets/ResultBannerView.swift index 48f55d1..8129b0e 100644 --- a/Baccarat/Baccarat/Views/Sheets/ResultBannerView.swift +++ b/Baccarat/Baccarat/Views/Sheets/ResultBannerView.swift @@ -3,12 +3,14 @@ // Baccarat // // Animated result banner showing the winner and itemized bet results. +// Uses the shared ResultBannerView from CasinoKit. // import SwiftUI import CasinoKit /// An animated banner showing the round result with bet breakdown. +/// This is a wrapper around CasinoKit's shared ResultBannerView. struct ResultBannerView: View { let result: GameResult let totalWinnings: Int @@ -20,30 +22,6 @@ struct ResultBannerView: View { 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 ? CasinoDesign.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] { @@ -59,28 +37,13 @@ struct ResultBannerView: View { } 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) - + CasinoResultBannerView( + resultText: result.displayText, + resultColor: result.color, + totalWinnings: totalWinnings, + currentBalance: currentBalance, + minBet: minBet, + breakdownContent: { // Pair indicators if playerHadPair || bankerHadPair { HStack(spacing: Design.Spacing.large) { @@ -91,8 +54,6 @@ struct ResultBannerView: View { PairBadge(label: "B PAIR", color: .red) } } - .scaleEffect(showBreakdown ? Design.Scale.normal : Design.Scale.shrunk) - .opacity(showBreakdown ? Design.Scale.normal : 0) } // Bet breakdown @@ -100,204 +61,13 @@ struct ResultBannerView: View { BetBreakdownView( winningBets: winningBets, losingBets: losingBets, - pushBets: pushBets, - fontSize: itemFontSize + pushBets: pushBets ) - .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.CasinoButton.goldLight, Color.CasinoButton.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.CasinoButton.goldLight, Color.CasinoButton.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() - } + }, + onNewRound: onNewRound, + onPlayAgain: onGameOver + ) } } @@ -307,10 +77,11 @@ private struct BetBreakdownView: View { let winningBets: [BetResult] let losingBets: [BetResult] let pushBets: [BetResult] - let fontSize: CGFloat + + @ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = Design.BaseFontSize.medium var body: some View { - VStack(spacing: Design.Spacing.xSmall) { + ResultBreakdownCard { // Winning bets ForEach(winningBets) { bet in BetResultRow(bet: bet, fontSize: fontSize) @@ -326,12 +97,6 @@ private struct BetBreakdownView: View { 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)) - ) } } @@ -400,7 +165,7 @@ private struct PairBadge: View { } } -// Note: ConfettiView is now provided by CasinoKit +// MARK: - Previews #Preview("Win") { ZStack { diff --git a/Blackjack/Blackjack/Resources/Localizable.xcstrings b/Blackjack/Blackjack/Resources/Localizable.xcstrings index 497fece..a44b782 100644 --- a/Blackjack/Blackjack/Resources/Localizable.xcstrings +++ b/Blackjack/Blackjack/Resources/Localizable.xcstrings @@ -2625,28 +2625,6 @@ } } }, - "Done" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Done" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Listo" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Terminé" - } - } - } - }, "Don't Ask" : { "localizations" : { "en" : { @@ -2669,6 +2647,28 @@ } } }, + "Done" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Listo" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminé" + } + } + } + }, "Double" : { "localizations" : { "en" : { @@ -4480,6 +4480,7 @@ } }, "New Round" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -4983,6 +4984,7 @@ } }, "Play Again" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5346,6 +5348,7 @@ }, "Round result: %@" : { "comment" : "An accessibility label for the round result banner, describing the main hand result.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -7019,6 +7022,7 @@ } }, "You've run out of chips!" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { diff --git a/Blackjack/Blackjack/Views/Sheets/ResultBannerView.swift b/Blackjack/Blackjack/Views/Sheets/ResultBannerView.swift index 048caa4..d0f6b4d 100644 --- a/Blackjack/Blackjack/Views/Sheets/ResultBannerView.swift +++ b/Blackjack/Blackjack/Views/Sheets/ResultBannerView.swift @@ -3,11 +3,13 @@ // Blackjack // // Displays the result of a round with breakdown. +// Uses the shared ResultBannerView from CasinoKit. // import SwiftUI import CasinoKit +/// Result banner for Blackjack using the shared CasinoKit component. struct ResultBannerView: View { let result: RoundResult let currentBalance: Int @@ -15,22 +17,9 @@ struct ResultBannerView: View { 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 - } - - /// Overall result based on total winnings (what the player actually cares about) + /// Overall result text based on total winnings private var overallResultText: String { if result.totalWinnings > 0 { return String(localized: "WIN!") @@ -48,60 +37,35 @@ struct ResultBannerView: View { return .blue } - 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) { - // Overall result based on total winnings - Text(overallResultText) - .font(.system(size: titleFontSize, weight: .black, design: .rounded)) - .foregroundStyle(overallResultColor) - - // 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) { + CasinoResultBannerView( + resultText: overallResultText, + resultColor: overallResultColor, + totalWinnings: result.totalWinnings, + currentBalance: currentBalance, + minBet: minBet, + breakdownContent: { + ResultBreakdownCard { + // Hand results 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( + + HandResultRow( label: handLabel, result: handResult, amount: showAmount ? handWinnings : nil ) } + // Insurance result if let insuranceResult = result.insuranceResult { let showInsAmount = result.insuranceWinnings != 0 - ResultRow( + HandResultRow( label: String(localized: "Insurance"), result: insuranceResult, amount: showInsAmount ? result.insuranceWinnings : nil @@ -127,124 +91,44 @@ struct ResultBannerView: View { ) } } - .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( - overallResultColor.opacity(Design.Opacity.medium), - lineWidth: Design.LineWidth.medium - ) - ) - ) - .shadow(color: overallResultColor.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) + }, + onNewRound: onNewRound, + onPlayAgain: onPlayAgain + ) } } -// MARK: - Result Row +// MARK: - Hand Result Row -struct ResultRow: View { +/// Row displaying a single hand result with optional amount. +private struct HandResultRow: View { let label: String let result: HandResult var amount: Int? = nil - private var showDebugBorders: Bool { Design.showDebugBorders } + @ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = Design.BaseFontSize.medium + + private var statusIcon: String { + switch result { + case .blackjack, .win, .insuranceWin: + return "checkmark.circle.fill" + case .lose, .bust, .insuranceLose: + return "xmark.circle.fill" + case .push: + return "arrow.left.arrow.right.circle.fill" + case .surrender: + return "flag.circle.fill" + } + } private var amountText: String? { guard let amount = amount else { return nil } if amount > 0 { - return "+$\(amount)" + return "+\(amount)" } else if amount < 0 { - return "-$\(abs(amount))" + return "\(amount)" } else { - return "$0" + return nil } } @@ -256,51 +140,56 @@ struct ResultRow: View { } var body: some View { - HStack { + HStack(spacing: Design.Spacing.small) { + // Status icon + Image(systemName: statusIcon) + .font(.system(size: fontSize)) + .foregroundStyle(result.color) + + // Label Text(label) - .font(.system(size: Design.BaseFontSize.medium)) - .foregroundStyle(.white.opacity(Design.Opacity.strong)) - .debugBorder(showDebugBorders, color: .blue, label: "Label") + .font(.system(size: fontSize, weight: .medium)) + .foregroundStyle(.white.opacity(Design.Opacity.heavy)) Spacer() - .debugBorder(showDebugBorders, color: .yellow, label: "Spacer") - // Show amount if provided + // Amount (if provided) if let amountText = amountText { Text(amountText) - .font(.system(size: Design.BaseFontSize.medium, weight: .semibold, design: .rounded)) + .font(.system(size: fontSize, weight: .semibold, design: .rounded)) .foregroundStyle(amountColor) - .frame(width: Design.Size.resultRowAmountWidth, alignment: .trailing) - .debugBorder(showDebugBorders, color: .green, label: "Amount") } + // Result text Text(result.displayText) - .font(.system(size: Design.BaseFontSize.large, weight: .bold)) + .font(.system(size: fontSize + 2, 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 { +/// Row displaying a side bet result. +private struct SideBetResultRow: View { let label: String let resultText: String let isWin: Bool let amount: Int - private var showDebugBorders: Bool { Design.showDebugBorders } + @ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = Design.BaseFontSize.medium + + private var statusIcon: String { + isWin ? "checkmark.circle.fill" : "xmark.circle.fill" + } private var amountText: String { if amount > 0 { - return "+$\(amount)" + return "+\(amount)" } else if amount < 0 { - return "-$\(abs(amount))" + return "\(amount)" } else { return "$0" } @@ -317,33 +206,36 @@ struct SideBetResultRow: View { } var body: some View { - HStack { + HStack(spacing: Design.Spacing.small) { + // Status icon + Image(systemName: statusIcon) + .font(.system(size: fontSize)) + .foregroundStyle(resultColor) + + // Label Text(label) - .font(.system(size: Design.BaseFontSize.medium)) - .foregroundStyle(.white.opacity(Design.Opacity.strong)) - .debugBorder(showDebugBorders, color: .blue, label: "Label") + .font(.system(size: fontSize, weight: .medium)) + .foregroundStyle(.white.opacity(Design.Opacity.heavy)) Spacer() - .debugBorder(showDebugBorders, color: .yellow, label: "Spacer") + // Amount Text(amountText) - .font(.system(size: Design.BaseFontSize.medium, weight: .semibold, design: .rounded)) + .font(.system(size: fontSize, weight: .semibold, design: .rounded)) .foregroundStyle(amountColor) - .frame(width: Design.Size.resultRowAmountWidth, alignment: .trailing) - .debugBorder(showDebugBorders, color: .green, label: "Amount") + // Result text Text(resultText) - .font(.system(size: Design.BaseFontSize.large, weight: .bold)) + .font(.system(size: fontSize + 2, 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") } } +// MARK: - Previews + #Preview("Single Hand") { ResultBannerView( result: RoundResult( @@ -416,3 +308,19 @@ struct SideBetResultRow: View { ) } +#Preview("Game Over") { + ResultBannerView( + result: RoundResult( + handResults: [.bust], + handWinnings: [-100], + insuranceResult: nil, + insuranceWinnings: 0, + totalWinnings: -100, + wasBlackjack: false + ), + currentBalance: 0, + minBet: 10, + onNewRound: {}, + onPlayAgain: {} + ) +} diff --git a/CasinoKit/Sources/CasinoKit/Exports.swift b/CasinoKit/Sources/CasinoKit/Exports.swift index cf58983..8cdc81f 100644 --- a/CasinoKit/Sources/CasinoKit/Exports.swift +++ b/CasinoKit/Sources/CasinoKit/Exports.swift @@ -27,6 +27,9 @@ // MARK: - Overlays // - GameOverView +// - CasinoResultBannerView (animated result banner with staggered animations) +// - ResultBreakdownCard (styled container for bet breakdown) +// - ResultItemRow (generic result row with icon, label, amount) // MARK: - Table // - TableBackgroundView, FeltPatternView diff --git a/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings b/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings index 377b5b2..e942d4d 100644 --- a/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings +++ b/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings @@ -165,6 +165,10 @@ } } }, + "+%lld" : { + "comment" : "A label displaying the total winnings amount in the result banner. The argument is the total winnings amount.", + "isCommentAutoGenerated" : true + }, "$" : { "comment" : "The dollar sign used in the top bar.", "isCommentAutoGenerated" : true, @@ -916,6 +920,10 @@ } } }, + "Game over. You've run out of chips." : { + "comment" : "Accessibility label for the result banner when the game is over and the user has run out of chips.", + "isCommentAutoGenerated" : true + }, "Game progress and statistics are stored locally on your device" : { "localizations" : { "en" : { @@ -1334,6 +1342,10 @@ } } }, + "New Round" : { + "comment" : "A button label that initiates a new round of a casino game.", + "isCommentAutoGenerated" : true + }, "Nine" : { "localizations" : { "en" : { @@ -1890,6 +1902,18 @@ } } }, + "TOTAL" : { + "comment" : "A label displayed alongside the total winnings in the result banner.", + "isCommentAutoGenerated" : true + }, + "Total loss: %lld" : { + "comment" : "A string that describes the total loss in a casino game. The argument is the absolute value of the total loss, formatted in U.S. Dollars.", + "isCommentAutoGenerated" : true + }, + "Total winnings: %lld" : { + "comment" : "A description of the result text that includes the total winnings. The argument is the total winnings, formatted as currency.", + "isCommentAutoGenerated" : true + }, "Two" : { "localizations" : { "en" : { diff --git a/CasinoKit/Sources/CasinoKit/Views/Overlays/ResultBannerView.swift b/CasinoKit/Sources/CasinoKit/Views/Overlays/ResultBannerView.swift new file mode 100644 index 0000000..678bd49 --- /dev/null +++ b/CasinoKit/Sources/CasinoKit/Views/Overlays/ResultBannerView.swift @@ -0,0 +1,504 @@ +// +// ResultBannerView.swift +// CasinoKit +// +// A reusable animated result banner for casino games. +// Each game provides its own breakdown content via a @ViewBuilder closure. +// + +import SwiftUI + +/// A reusable result banner with staggered animations. +/// +/// This component provides the shared structure for result displays: +/// - Animated dark overlay +/// - Result text with gradient and glow +/// - Game-specific breakdown content (provided via @ViewBuilder) +/// - Total winnings display +/// - New Round / Play Again button +/// - Game over handling +/// +/// Usage: +/// ```swift +/// CasinoResultBannerView( +/// resultText: "WIN!", +/// resultColor: .green, +/// totalWinnings: 500, +/// currentBalance: 10500, +/// minBet: 10, +/// breakdownContent: { +/// // Your game-specific breakdown views here +/// ForEach(betResults) { bet in +/// BetResultRow(bet: bet) +/// } +/// }, +/// onNewRound: { startNewRound() }, +/// onPlayAgain: { resetGame() } +/// ) +/// ``` +public struct CasinoResultBannerView: View { + + // MARK: - Parameters + + /// The result text to display (e.g., "WIN!", "PLAYER WINS", "BLACKJACK!") + public let resultText: String + + /// The color associated with the result (green for win, red for loss, etc.) + public let resultColor: Color + + /// Total winnings/losses for this round + public let totalWinnings: Int + + /// Current balance to determine if game over + public let currentBalance: Int + + /// Minimum bet to determine if player can continue + public let minBet: Int + + /// Game-specific breakdown content (bet results, hand results, etc.) + @ViewBuilder public let breakdownContent: Content + + /// Action when "New Round" is tapped + public let onNewRound: () -> Void + + /// Action when "Play Again" is tapped (game over state) + public let onPlayAgain: () -> Void + + // MARK: - Animation States + + @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 + + // MARK: - Environment + + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + // MARK: - Scaled Font Sizes (Dynamic Type) + + @ScaledMetric(relativeTo: .largeTitle) private var resultFontSize: CGFloat = CasinoDesign.BaseFontSize.largeTitle + @ScaledMetric(relativeTo: .title3) private var totalFontSize: CGFloat = CasinoDesign.BaseFontSize.xxLarge + CasinoDesign.Spacing.xSmall + @ScaledMetric(relativeTo: .headline) private var buttonFontSize: CGFloat = CasinoDesign.BaseFontSize.xLarge + + // MARK: - Computed Properties + + /// Whether the player is out of money and can't continue. + private var isGameOver: Bool { + currentBalance < minBet + } + + /// Maximum width for the banner card on iPad + private var maxBannerWidth: CGFloat { + horizontalSizeClass == .regular ? CasinoDesign.Size.maxModalWidth : .infinity + } + + // MARK: - Initializer + + /// Creates a result banner view. + /// - Parameters: + /// - resultText: The main result text to display. + /// - resultColor: The color for the result (affects text gradient, border, shadow). + /// - totalWinnings: The total amount won or lost this round. + /// - currentBalance: The player's current balance (for game over detection). + /// - minBet: The minimum bet amount (for game over detection). + /// - breakdownContent: Game-specific content showing bet/hand breakdown. + /// - onNewRound: Action when "New Round" button is tapped. + /// - onPlayAgain: Action when "Play Again" button is tapped (game over state). + public init( + resultText: String, + resultColor: Color, + totalWinnings: Int, + currentBalance: Int, + minBet: Int, + @ViewBuilder breakdownContent: () -> Content, + onNewRound: @escaping () -> Void, + onPlayAgain: @escaping () -> Void + ) { + self.resultText = resultText + self.resultColor = resultColor + self.totalWinnings = totalWinnings + self.currentBalance = currentBalance + self.minBet = minBet + self.breakdownContent = breakdownContent() + self.onNewRound = onNewRound + self.onPlayAgain = onPlayAgain + } + + // MARK: - Body + + public var body: some View { + ZStack { + // Background overlay + Color.black.opacity(showBanner ? CasinoDesign.Opacity.medium : 0) + .ignoresSafeArea() + .animation(.easeIn(duration: CasinoDesign.Animation.fadeInDuration), value: showBanner) + + // Banner card + VStack(spacing: CasinoDesign.Spacing.medium) { + // Result text with gradient and glow + Text(resultText) + .font(.system(size: resultFontSize, weight: .black, design: .rounded)) + .foregroundStyle( + LinearGradient( + colors: [.white, resultColor], + startPoint: .top, + endPoint: .bottom + ) + ) + .shadow(color: resultColor.opacity(CasinoDesign.Opacity.heavy), radius: CasinoDesign.Shadow.radiusLarge) + .scaleEffect(showText ? CasinoDesign.Scale.normal : CasinoDesign.Scale.shrunk) + .opacity(showText ? CasinoDesign.Scale.normal : 0) + + // Game-specific breakdown content + breakdownContent + .scaleEffect(showBreakdown ? CasinoDesign.Scale.normal : CasinoDesign.Scale.shrunk) + .opacity(showBreakdown ? CasinoDesign.Scale.normal : 0) + + // Total winnings + if totalWinnings != 0 { + totalWinningsView + .scaleEffect(showTotal ? CasinoDesign.Scale.normal : CasinoDesign.Scale.shrunk) + .opacity(showTotal ? CasinoDesign.Scale.normal : 0) + } + + // Button section + buttonSection + .scaleEffect(showButton ? CasinoDesign.Scale.normal : CasinoDesign.Scale.shrunk) + .opacity(showButton ? CasinoDesign.Scale.normal : 0) + .padding(.top, CasinoDesign.Spacing.small) + } + .padding(.horizontal, CasinoDesign.Spacing.xLarge) + .padding(.vertical, CasinoDesign.Spacing.xxLarge) + .frame(maxWidth: maxBannerWidth) + .background(bannerBackground) + .shadow(color: resultColor.opacity(CasinoDesign.Opacity.light), radius: CasinoDesign.Shadow.radiusXXLarge) + .padding(.horizontal, CasinoDesign.Spacing.large) + .scaleEffect(showBanner ? CasinoDesign.Scale.normal : CasinoDesign.Scale.slightShrink) + .opacity(showBanner ? CasinoDesign.Scale.normal : 0) + } + .onAppear { + startAnimations() + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityDescription) + } + + // MARK: - Subviews + + private var totalWinningsView: some View { + HStack(spacing: CasinoDesign.Spacing.small) { + Text(String(localized: "TOTAL", bundle: .module)) + .font(.system(size: CasinoDesign.BaseFontSize.body, weight: .bold)) + .foregroundStyle(.white.opacity(CasinoDesign.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) + } + } + } + + @ViewBuilder + private var buttonSection: some View { + if isGameOver { + // Game Over - show message and restart button + VStack(spacing: CasinoDesign.Spacing.medium) { + Text(String(localized: "You've run out of chips!", bundle: .module)) + .font(.system(size: CasinoDesign.BaseFontSize.medium, weight: .medium)) + .foregroundStyle(.red.opacity(CasinoDesign.Opacity.heavy)) + + Button { + onPlayAgain() + } label: { + HStack(spacing: CasinoDesign.Spacing.small) { + Image(systemName: "arrow.counterclockwise") + Text(String(localized: "Play Again", bundle: .module)) + } + .font(.system(size: buttonFontSize, weight: .bold)) + .foregroundStyle(.black) + .padding(.horizontal, CasinoDesign.Spacing.xxxLarge + CasinoDesign.Spacing.small) + .padding(.vertical, CasinoDesign.Spacing.medium + CasinoDesign.Spacing.xxSmall) + .background(goldButtonBackground) + } + } + } else { + // Normal - New Round button + Button { + onNewRound() + } label: { + Text(String(localized: "New Round", bundle: .module)) + .font(.system(size: buttonFontSize, weight: .bold)) + .foregroundStyle(.black) + .padding(.horizontal, CasinoDesign.Spacing.xxxLarge + CasinoDesign.Spacing.small) + .padding(.vertical, CasinoDesign.Spacing.medium + CasinoDesign.Spacing.xxSmall) + .background(goldButtonBackground) + } + } + } + + private var goldButtonBackground: some View { + Capsule() + .fill( + LinearGradient( + colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark], + startPoint: .top, + endPoint: .bottom + ) + ) + .shadow(color: .yellow.opacity(CasinoDesign.Opacity.light), radius: CasinoDesign.Shadow.radiusMedium) + } + + private var bannerBackground: some View { + RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.xxLarge) + .fill( + LinearGradient( + colors: [ + Color.CasinoModal.backgroundLight, + Color.CasinoModal.backgroundDark + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.xxLarge) + .strokeBorder( + LinearGradient( + colors: [ + resultColor.opacity(CasinoDesign.Opacity.heavy), + resultColor.opacity(CasinoDesign.Opacity.light) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: CasinoDesign.LineWidth.thick + ) + ) + } + + // MARK: - Animations + + private func startAnimations() { + withAnimation(.spring(duration: CasinoDesign.Animation.springDuration, bounce: CasinoDesign.Animation.springBounce)) { + showBanner = true + } + + withAnimation(.spring(duration: CasinoDesign.Animation.springDuration, bounce: CasinoDesign.Animation.springBounce).delay(CasinoDesign.Animation.staggerDelay1)) { + showText = true + } + + withAnimation(.spring(duration: CasinoDesign.Animation.springDuration, bounce: CasinoDesign.Animation.springBounce).delay(CasinoDesign.Animation.staggerDelay2)) { + showBreakdown = true + } + + withAnimation(.spring(duration: CasinoDesign.Animation.springDuration, bounce: CasinoDesign.Animation.springBounce).delay(CasinoDesign.Animation.staggerDelay3)) { + showTotal = true + } + + // Show button after everything else + withAnimation(.spring(duration: CasinoDesign.Animation.springDuration, bounce: CasinoDesign.Animation.springBounce).delay(CasinoDesign.Animation.staggerDelay3 + CasinoDesign.Animation.staggerDelay1)) { + showButton = true + } + + // Play game over sound if out of chips + if isGameOver { + Task { + try? await Task.sleep(for: .seconds(1)) + SoundManager.shared.play(.gameOver) + } + } + + // Announce result to VoiceOver users + announceResult() + } + + // MARK: - Accessibility + + private var accessibilityDescription: String { + var description = resultText + + if totalWinnings > 0 { + description += ". " + String(localized: "Total winnings: \(totalWinnings)", bundle: .module) + } else if totalWinnings < 0 { + description += ". " + String(localized: "Total loss: \(abs(totalWinnings))", bundle: .module) + } + + if isGameOver { + description += ". " + String(localized: "Game over. You've run out of chips.", bundle: .module) + } + + return description + } + + private func announceResult() { + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(500)) + AccessibilityNotification.Announcement(accessibilityDescription).post() + } + } +} + +// MARK: - Result Breakdown Card + +/// A styled container for bet/hand breakdown content. +/// Provides consistent styling across all casino games. +public struct ResultBreakdownCard: View { + @ViewBuilder public let content: Content + + public init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + public var body: some View { + VStack(spacing: CasinoDesign.Spacing.xSmall) { + content + } + .padding(.horizontal, CasinoDesign.Spacing.medium) + .padding(.vertical, CasinoDesign.Spacing.small) + .background( + RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium) + .fill(Color.white.opacity(CasinoDesign.Opacity.verySubtle)) + ) + } +} + +// MARK: - Result Row + +/// A generic row for displaying a result item with label, optional amount, and status. +public struct ResultItemRow: View { + public let label: String + public let statusText: String + public let statusColor: Color + public var amount: Int? + public var statusIcon: String? + + @ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = CasinoDesign.BaseFontSize.medium + + public init( + label: String, + statusText: String, + statusColor: Color, + amount: Int? = nil, + statusIcon: String? = nil + ) { + self.label = label + self.statusText = statusText + self.statusColor = statusColor + self.amount = amount + self.statusIcon = statusIcon + } + + private var amountText: String? { + guard let amount = amount else { return nil } + if amount > 0 { + return "+\(amount)" + } else if amount < 0 { + return "\(amount)" + } else { + return nil + } + } + + private var amountColor: Color { + guard let amount = amount else { return .white } + if amount > 0 { return .green } + if amount < 0 { return .red } + return .blue + } + + public var body: some View { + HStack(spacing: CasinoDesign.Spacing.small) { + // Status icon (if provided) + if let icon = statusIcon { + Image(systemName: icon) + .font(.system(size: fontSize)) + .foregroundStyle(statusColor) + } + + // Label + Text(label) + .font(.system(size: fontSize, weight: .medium)) + .foregroundStyle(.white.opacity(CasinoDesign.Opacity.heavy)) + + Spacer() + + // Amount (if provided) + if let amountText = amountText { + Text(amountText) + .font(.system(size: fontSize, weight: .semibold, design: .rounded)) + .foregroundStyle(amountColor) + } + + // Status text + Text(statusText) + .font(.system(size: fontSize + 2, weight: .bold)) + .foregroundStyle(statusColor) + } + } +} + +// MARK: - Preview + +#Preview("Win Result") { + ZStack { + Color.CasinoTable.felt + .ignoresSafeArea() + + CasinoResultBannerView( + resultText: "WIN!", + resultColor: .green, + totalWinnings: 500, + currentBalance: 10500, + minBet: 10, + breakdownContent: { + ResultBreakdownCard { + ResultItemRow( + label: "Main Hand", + statusText: "WIN", + statusColor: .green, + amount: 500, + statusIcon: "checkmark.circle.fill" + ) + } + }, + onNewRound: {}, + onPlayAgain: {} + ) + } +} + +#Preview("Game Over") { + ZStack { + Color.CasinoTable.felt + .ignoresSafeArea() + + CasinoResultBannerView( + resultText: "LOSE", + resultColor: .red, + totalWinnings: -100, + currentBalance: 0, + minBet: 10, + breakdownContent: { + ResultBreakdownCard { + ResultItemRow( + label: "Main Hand", + statusText: "BUST", + statusColor: .red, + amount: -100, + statusIcon: "xmark.circle.fill" + ) + } + }, + onNewRound: {}, + onPlayAgain: {} + ) + } +} +