CasinoGames/Baccarat/Views/ResultBannerView.swift

202 lines
7.6 KiB
Swift

//
// ResultBannerView.swift
// Baccarat
//
// Animated result banner showing the winner and winnings.
//
import SwiftUI
import UIKit
/// An animated banner showing the round result.
struct ResultBannerView: View {
let result: GameResult
let winnings: Int
@State private var showBanner = false
@State private var showText = false
@State private var showWinnings = false
// MARK: - Scaled Font Sizes (Dynamic Type)
@ScaledMetric(relativeTo: .largeTitle) private var resultFontSize: CGFloat = Design.BaseFontSize.largeTitle
@ScaledMetric(relativeTo: .title2) private var winningsFontSize: CGFloat = 28
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.xLarge) {
// 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)
// Winnings display
if winnings != 0 {
HStack(spacing: Design.Spacing.small) {
if winnings > 0 {
Image(systemName: "plus.circle.fill")
.foregroundStyle(.green)
Text("\(winnings)")
.foregroundStyle(.green)
} else {
Image(systemName: "minus.circle.fill")
.foregroundStyle(.red)
Text("\(abs(winnings))")
.foregroundStyle(.red)
}
}
.font(.system(size: winningsFontSize, weight: .bold, design: .rounded))
.scaleEffect(showWinnings ? Design.Scale.normal : Design.Scale.shrunk)
.opacity(showWinnings ? Design.Scale.normal : 0)
}
}
.padding(Design.Spacing.xxxLarge + Design.Spacing.small)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge + Design.Spacing.xSmall)
.fill(
LinearGradient(
colors: [
Color(white: 0.15),
Color(white: 0.08)
],
startPoint: .top,
endPoint: .bottom
)
)
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge + Design.Spacing.xSmall)
.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)
.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)) {
showWinnings = true
}
// Announce result to VoiceOver users
announceResult()
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription)
.accessibilityAddTraits(.updatesFrequently)
}
// MARK: - Accessibility
private var accessibilityDescription: String {
var description = result.displayText
if winnings > 0 {
let format = String(localized: "wonAmountFormat")
description += ". " + String(format: format, winnings.formatted())
} else if winnings < 0 {
let format = String(localized: "lostAmountFormat")
description += ". " + String(format: format, abs(winnings).formatted())
}
return description
}
private func announceResult() {
// Post accessibility announcement for screen reader users
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(500))
UIAccessibility.post(notification: .announcement, argument: accessibilityDescription)
}
}
}
/// Confetti particle for celebrations.
struct ConfettiPiece: View {
let color: Color
@State private var position: CGPoint = .zero
@State private var rotation: Double = 0
@State private var opacity: Double = 1
private let confettiWidth: CGFloat = 8
private let confettiHeight: CGFloat = 12
var body: some View {
Rectangle()
.fill(color)
.frame(width: confettiWidth, height: confettiHeight)
.rotationEffect(.degrees(rotation))
.position(position)
.opacity(opacity)
.onAppear {
let screenWidth = 400.0
let startX = Double.random(in: 0...screenWidth)
position = CGPoint(x: startX, y: -20)
withAnimation(.easeIn(duration: Double.random(in: 2...4))) {
position = CGPoint(
x: startX + Double.random(in: -100...100),
y: 800
)
rotation = Double.random(in: 360...1080)
opacity = 0
}
}
}
}
/// A confetti celebration overlay.
struct ConfettiView: View {
let colors: [Color] = [.red, .blue, .green, .yellow, .orange, .purple, .pink]
var body: some View {
ZStack {
ForEach(0..<50, id: \.self) { _ in
ConfettiPiece(color: colors.randomElement() ?? .yellow)
}
}
.allowsHitTesting(false)
.accessibilityHidden(true) // Decorative element
}
}
#Preview {
ZStack {
Color.Table.preview
.ignoresSafeArea()
ResultBannerView(result: .playerWins, winnings: 500)
}
}