Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-12-17 13:41:37 -06:00
parent 6ae84c02eb
commit d2b9688b30
4 changed files with 142 additions and 53 deletions

View File

@ -83,6 +83,11 @@ enum Design {
static let topBetRowHeight: CGFloat = 52 static let topBetRowHeight: CGFloat = 52
static let mainBetRowHeight: CGFloat = 65 static let mainBetRowHeight: CGFloat = 65
static let bonusZoneWidth: CGFloat = 80 static let bonusZoneWidth: CGFloat = 80
// iPad max widths
static let maxContentWidthPortrait: CGFloat = 500
static let maxContentWidthLandscape: CGFloat = 800
static let maxModalWidth: CGFloat = 450
} }
// MARK: - Animation // MARK: - Animation

View File

@ -17,6 +17,24 @@ struct GameTableView: View {
@State private var showRules = false @State private var showRules = false
@State private var showStats = false @State private var showStats = false
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass
/// Whether we're on iPad or large screen
private var isLargeScreen: Bool {
horizontalSizeClass == .regular
}
/// Whether we're in landscape mode (compact vertical on iPad)
private var isLandscape: Bool {
verticalSizeClass == .compact
}
/// Maximum width for game content on large screens
private var maxContentWidth: CGFloat {
isLandscape ? Design.Size.maxContentWidthLandscape : Design.Size.maxContentWidthPortrait
}
private var state: GameState { private var state: GameState {
gameState ?? GameState(settings: settings) gameState ?? GameState(settings: settings)
} }
@ -53,7 +71,7 @@ struct GameTableView: View {
Spacer(minLength: Design.Spacing.xSmall) Spacer(minLength: Design.Spacing.xSmall)
// Cards display area // Cards display area - constrained width on iPad
CardsDisplayArea( CardsDisplayArea(
playerCards: state.visiblePlayerCards, playerCards: state.visiblePlayerCards,
bankerCards: state.visibleBankerCards, bankerCards: state.visibleBankerCards,
@ -65,36 +83,40 @@ struct GameTableView: View {
bankerIsWinner: bankerIsWinner, bankerIsWinner: bankerIsWinner,
isTie: isTie isTie: isTie
) )
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
Spacer(minLength: Design.Spacing.xSmall) Spacer(minLength: Design.Spacing.xSmall)
// Road map history // Road map history - constrained width on iPad
if settings.showHistory && !state.roundHistory.isEmpty { if settings.showHistory && !state.roundHistory.isEmpty {
RoadMapView(results: state.recentResults) RoadMapView(results: state.recentResults)
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
.padding(.horizontal) .padding(.horizontal)
} }
Spacer(minLength: Design.Spacing.small) Spacer(minLength: Design.Spacing.small)
// Mini Baccarat betting table // Mini Baccarat betting table - constrained width on iPad
MiniBaccaratTableView( MiniBaccaratTableView(
gameState: state, gameState: state,
selectedChip: selectedChip selectedChip: selectedChip
) )
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
.padding(.horizontal, Design.Spacing.medium) .padding(.horizontal, Design.Spacing.medium)
Spacer(minLength: Design.Spacing.medium) Spacer(minLength: Design.Spacing.medium)
// Chip selector - shows higher chips as you win more! // Chip selector - constrained width on iPad
ChipSelectorView( ChipSelectorView(
selectedChip: $selectedChip, selectedChip: $selectedChip,
balance: state.balance, balance: state.balance,
maxBet: state.maxBet maxBet: state.maxBet
) )
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
Spacer(minLength: Design.Spacing.small) Spacer(minLength: Design.Spacing.small)
// Action buttons // Action buttons - constrained width on iPad
ActionButtonsView( ActionButtonsView(
gameState: state, gameState: state,
onDeal: { onDeal: {
@ -105,12 +127,13 @@ struct GameTableView: View {
onClear: { state.clearBets() }, onClear: { state.clearBets() },
onNewRound: { state.newRound() } onNewRound: { state.newRound() }
) )
.frame(maxWidth: isLargeScreen ? maxContentWidth * 0.8 : .infinity)
.padding(.horizontal) .padding(.horizontal)
.padding(.bottom, Design.Spacing.xSmall) .padding(.bottom, Design.Spacing.xSmall)
} }
.safeAreaPadding(.bottom) .safeAreaPadding(.bottom)
// Result banner overlay // Result banner overlay (handles its own iPad sizing)
if state.showResultBanner, let result = state.lastResult { if state.showResultBanner, let result = state.lastResult {
ResultBannerView( ResultBannerView(
result: result, result: result,
@ -134,7 +157,7 @@ struct GameTableView: View {
} }
} }
// Game Over overlay when broke // Game Over overlay (handles its own iPad sizing)
if state.balance == 0 && state.currentBets.isEmpty && !state.isAnimating { if state.balance == 0 && state.currentBets.isEmpty && !state.isAnimating {
GameOverView( GameOverView(
roundsPlayed: state.roundHistory.count, roundsPlayed: state.roundHistory.count,
@ -171,6 +194,12 @@ struct GameOverView: View {
let onPlayAgain: () -> Void let onPlayAgain: () -> Void
@State private var showContent = false @State private var showContent = false
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
/// Maximum width for the modal card on iPad
private var maxModalWidth: CGFloat {
horizontalSizeClass == .regular ? Design.Size.maxModalWidth : .infinity
}
// MARK: - Scaled Font Sizes (Dynamic Type) // MARK: - Scaled Font Sizes (Dynamic Type)
@ -290,6 +319,7 @@ struct GameOverView: View {
) )
) )
.shadow(color: .red.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusXXLarge) .shadow(color: .red.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusXXLarge)
.frame(maxWidth: maxModalWidth)
.padding(.horizontal, Design.Spacing.xxLarge) .padding(.horizontal, Design.Spacing.xxLarge)
.scaleEffect(showContent ? Design.Scale.normal : Design.Scale.slightShrink) .scaleEffect(showContent ? Design.Scale.normal : Design.Scale.slightShrink)
.opacity(showContent ? 1.0 : 0) .opacity(showContent ? 1.0 : 0)
@ -434,10 +464,31 @@ struct CompactHandView: View {
// Fixed size: cards have strict visual constraints // Fixed size: cards have strict visual constraints
private let cardWidth: CGFloat = 45 private let cardWidth: CGFloat = 45
private let cardHeight: CGFloat = 63 // Standard card aspect ratio ~1.4
private let cardOverlap: CGFloat = -12 private let cardOverlap: CGFloat = -12
private let placeholderSpacing: CGFloat = 8 private let placeholderSpacing: CGFloat = 8
/// Fixed container width to prevent resizing during deal
/// Calculated as: 3 cards with overlap + padding
/// = cardWidth + (cardWidth + overlap) * 2 + padding * 2
private var fixedContainerWidth: CGFloat {
// Max 3 cards: first card full width + 2 more with overlap
let cardsWidth = cardWidth + (cardWidth + cardOverlap) * 2
return cardsWidth + Design.Spacing.xSmall * 2
}
/// Fixed container height
private var fixedContainerHeight: CGFloat {
cardHeight + Design.Spacing.xSmall * 2
}
var body: some View { var body: some View {
ZStack {
// Fixed-size container
Color.clear
.frame(width: fixedContainerWidth, height: fixedContainerHeight)
// Cards content centered in fixed container
HStack(spacing: cards.isEmpty ? placeholderSpacing : cardOverlap) { HStack(spacing: cards.isEmpty ? placeholderSpacing : cardOverlap) {
if cards.isEmpty { if cards.isEmpty {
// Placeholders - no overlap, just side by side // Placeholders - no overlap, just side by side
@ -456,7 +507,7 @@ struct CompactHandView: View {
} }
} }
} }
.padding(Design.Spacing.xSmall) }
.background( .background(
RoundedRectangle(cornerRadius: Design.CornerRadius.small) RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.strokeBorder( .strokeBorder(
@ -692,14 +743,23 @@ struct ActionButtonsView: View {
private let iconSize: CGFloat = Design.BaseFontSize.xxLarge + Design.Spacing.xSmall private let iconSize: CGFloat = Design.BaseFontSize.xxLarge + Design.Spacing.xSmall
private let statusFontSize: CGFloat = Design.BaseFontSize.medium private let statusFontSize: CGFloat = Design.BaseFontSize.medium
/// Fixed height to prevent layout shifts when buttons change
private let containerHeight: CGFloat = 50
var body: some View { var body: some View {
HStack(spacing: Design.Spacing.medium) { ZStack {
// Fixed height container to prevent layout shifts
Color.clear
.frame(height: containerHeight)
// Content changes with animation
Group {
if gameState.currentPhase == .betting { if gameState.currentPhase == .betting {
// Clear bets button - icon only at accessibility sizes // Clear bets button - icon only at accessibility sizes
HStack(spacing: Design.Spacing.medium) {
clearButton clearButton
// Deal button - icon only at accessibility sizes
dealButton dealButton
}
} else if gameState.currentPhase == .roundComplete && !gameState.showResultBanner { } else if gameState.currentPhase == .roundComplete && !gameState.showResultBanner {
// New round button - only shown after banner is dismissed // New round button - only shown after banner is dismissed
// (The banner itself has a New Round button) // (The banner itself has a New Round button)
@ -720,6 +780,9 @@ struct ActionButtonsView: View {
.padding(.vertical, Design.Spacing.medium) .padding(.vertical, Design.Spacing.medium)
} }
} }
.animation(.easeInOut(duration: Design.Animation.quick), value: gameState.currentPhase)
.animation(.easeInOut(duration: Design.Animation.quick), value: gameState.showResultBanner)
}
} }
@ViewBuilder @ViewBuilder

View File

@ -31,6 +31,13 @@ struct ResultBannerView: View {
@State private var showTotal = false @State private var showTotal = false
@State private var showButton = 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) // MARK: - Scaled Font Sizes (Dynamic Type)
@ScaledMetric(relativeTo: .largeTitle) private var resultFontSize: CGFloat = Design.BaseFontSize.largeTitle @ScaledMetric(relativeTo: .largeTitle) private var resultFontSize: CGFloat = Design.BaseFontSize.largeTitle
@ -185,6 +192,7 @@ struct ResultBannerView: View {
} }
.padding(.horizontal, Design.Spacing.xLarge) .padding(.horizontal, Design.Spacing.xLarge)
.padding(.vertical, Design.Spacing.xxLarge) .padding(.vertical, Design.Spacing.xxLarge)
.frame(maxWidth: maxBannerWidth)
.background( .background(
RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge) RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge)
.fill( .fill(
@ -213,6 +221,7 @@ struct ResultBannerView: View {
) )
) )
.shadow(color: result.color.opacity(Design.Opacity.light), radius: Design.Shadow.radiusXXLarge) .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) .scaleEffect(showBanner ? Design.Scale.normal : Design.Scale.slightShrink)
.opacity(showBanner ? Design.Scale.normal : 0) .opacity(showBanner ? Design.Scale.normal : 0)
} }
@ -395,6 +404,8 @@ private struct PairBadge: View {
/// Confetti particle for celebrations. /// Confetti particle for celebrations.
struct ConfettiPiece: View { struct ConfettiPiece: View {
let color: Color let color: Color
let containerSize: CGSize
@State private var position: CGPoint = .zero @State private var position: CGPoint = .zero
@State private var rotation: Double = 0 @State private var rotation: Double = 0
@State private var opacity: Double = 1 @State private var opacity: Double = 1
@ -410,14 +421,13 @@ struct ConfettiPiece: View {
.position(position) .position(position)
.opacity(opacity) .opacity(opacity)
.onAppear { .onAppear {
let screenWidth = 400.0 let startX = Double.random(in: 0...containerSize.width)
let startX = Double.random(in: 0...screenWidth)
position = CGPoint(x: startX, y: -20) position = CGPoint(x: startX, y: -20)
withAnimation(.easeIn(duration: Double.random(in: 2...4))) { withAnimation(.easeIn(duration: Double.random(in: 2...4))) {
position = CGPoint( position = CGPoint(
x: startX + Double.random(in: -100...100), x: startX + Double.random(in: -100...100),
y: 800 y: containerSize.height + 50
) )
rotation = Double.random(in: 360...1080) rotation = Double.random(in: 360...1080)
opacity = 0 opacity = 0
@ -431,11 +441,17 @@ struct ConfettiView: View {
let colors: [Color] = [.red, .blue, .green, .yellow, .orange, .purple, .pink] let colors: [Color] = [.red, .blue, .green, .yellow, .orange, .purple, .pink]
var body: some View { var body: some View {
GeometryReader { geometry in
ZStack { ZStack {
ForEach(0..<50, id: \.self) { _ in ForEach(0..<50, id: \.self) { _ in
ConfettiPiece(color: colors.randomElement() ?? .yellow) ConfettiPiece(
color: colors.randomElement() ?? .yellow,
containerSize: geometry.size
)
} }
} }
}
.ignoresSafeArea()
.allowsHitTesting(false) .allowsHitTesting(false)
.accessibilityHidden(true) .accessibilityHidden(true)
} }

View File

@ -134,6 +134,7 @@ struct SettingsView: View {
} }
} }
.tint(.yellow) .tint(.yellow)
.padding(.vertical, Design.Spacing.xSmall)
if gameState.iCloudEnabled { if gameState.iCloudEnabled {
Divider() Divider()
@ -186,6 +187,7 @@ struct SettingsView: View {
.foregroundStyle(.white.opacity(Design.Opacity.medium)) .foregroundStyle(.white.opacity(Design.Opacity.medium))
} }
} }
.padding(.vertical, Design.Spacing.xSmall)
} }
} }
@ -380,6 +382,7 @@ struct SettingsToggle: View {
} }
} }
.tint(.yellow) .tint(.yellow)
.padding(.vertical, Design.Spacing.xSmall)
} }
} }
@ -418,6 +421,7 @@ struct SpeedPicker: View {
} }
} }
} }
.padding(.vertical, Design.Spacing.xSmall)
} }
} }
@ -515,6 +519,7 @@ struct VolumePicker: View {
.foregroundStyle(.white.opacity(Design.Opacity.medium)) .foregroundStyle(.white.opacity(Design.Opacity.medium))
} }
} }
.padding(.vertical, Design.Spacing.xSmall)
} }
} }