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,29 +464,50 @@ 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 {
HStack(spacing: cards.isEmpty ? placeholderSpacing : cardOverlap) { ZStack {
if cards.isEmpty { // Fixed-size container
// Placeholders - no overlap, just side by side Color.clear
ForEach(0..<2, id: \.self) { _ in .frame(width: fixedContainerWidth, height: fixedContainerHeight)
CardPlaceholderView(width: cardWidth)
} // Cards content centered in fixed container
} else { HStack(spacing: cards.isEmpty ? placeholderSpacing : cardOverlap) {
ForEach(cards.indices, id: \.self) { index in if cards.isEmpty {
let isFaceUp = index < cardsFaceUp.count ? cardsFaceUp[index] : false // Placeholders - no overlap, just side by side
CardView( ForEach(0..<2, id: \.self) { _ in
card: cards[index], CardPlaceholderView(width: cardWidth)
isFaceUp: isFaceUp, }
cardWidth: cardWidth } else {
) ForEach(cards.indices, id: \.self) { index in
.zIndex(Double(index)) let isFaceUp = index < cardsFaceUp.count ? cardsFaceUp[index] : false
CardView(
card: cards[index],
isFaceUp: isFaceUp,
cardWidth: cardWidth
)
.zIndex(Double(index))
}
} }
} }
} }
.padding(Design.Spacing.xSmall)
.background( .background(
RoundedRectangle(cornerRadius: Design.CornerRadius.small) RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.strokeBorder( .strokeBorder(
@ -692,33 +743,45 @@ 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 {
if gameState.currentPhase == .betting { // Fixed height container to prevent layout shifts
// Clear bets button - icon only at accessibility sizes Color.clear
clearButton .frame(height: containerHeight)
// Deal button - icon only at accessibility sizes // Content changes with animation
dealButton Group {
} else if gameState.currentPhase == .roundComplete && !gameState.showResultBanner { if gameState.currentPhase == .betting {
// New round button - only shown after banner is dismissed // Clear bets button - icon only at accessibility sizes
// (The banner itself has a New Round button) HStack(spacing: Design.Spacing.medium) {
newRoundButton clearButton
} else if !gameState.showResultBanner { dealButton
// Playing indicator }
HStack(spacing: Design.Spacing.xSmall) { } else if gameState.currentPhase == .roundComplete && !gameState.showResultBanner {
ProgressView() // New round button - only shown after banner is dismissed
.tint(.white) // (The banner itself has a New Round button)
.scaleEffect(0.8) newRoundButton
Text("Dealing...") } else if !gameState.showResultBanner {
.font(.system(size: statusFontSize, weight: .medium)) // Playing indicator
.foregroundStyle(.white.opacity(Design.Opacity.heavy)) HStack(spacing: Design.Spacing.xSmall) {
.lineLimit(1) ProgressView()
.minimumScaleFactor(Design.MinScaleFactor.relaxed) .tint(.white)
.scaleEffect(0.8)
Text("Dealing...")
.font(.system(size: statusFontSize, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.heavy))
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.relaxed)
}
.padding(.horizontal, Design.Spacing.xLarge)
.padding(.vertical, Design.Spacing.medium)
} }
.padding(.horizontal, Design.Spacing.xLarge)
.padding(.vertical, Design.Spacing.medium)
} }
.animation(.easeInOut(duration: Design.Animation.quick), value: gameState.currentPhase)
.animation(.easeInOut(duration: Design.Animation.quick), value: gameState.showResultBanner)
} }
} }

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 {
ZStack { GeometryReader { geometry in
ForEach(0..<50, id: \.self) { _ in ZStack {
ConfettiPiece(color: colors.randomElement() ?? .yellow) ForEach(0..<50, id: \.self) { _ in
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)
} }
} }