diff --git a/Baccarat/Theme/DesignConstants.swift b/Baccarat/Theme/DesignConstants.swift index 1462f06..ef46171 100644 --- a/Baccarat/Theme/DesignConstants.swift +++ b/Baccarat/Theme/DesignConstants.swift @@ -83,6 +83,11 @@ enum Design { static let topBetRowHeight: CGFloat = 52 static let mainBetRowHeight: CGFloat = 65 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 diff --git a/Baccarat/Views/GameTableView.swift b/Baccarat/Views/GameTableView.swift index ca947ae..2f8fe75 100644 --- a/Baccarat/Views/GameTableView.swift +++ b/Baccarat/Views/GameTableView.swift @@ -17,6 +17,24 @@ struct GameTableView: View { @State private var showRules = 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 { gameState ?? GameState(settings: settings) } @@ -53,7 +71,7 @@ struct GameTableView: View { Spacer(minLength: Design.Spacing.xSmall) - // Cards display area + // Cards display area - constrained width on iPad CardsDisplayArea( playerCards: state.visiblePlayerCards, bankerCards: state.visibleBankerCards, @@ -65,36 +83,40 @@ struct GameTableView: View { bankerIsWinner: bankerIsWinner, isTie: isTie ) + .frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity) Spacer(minLength: Design.Spacing.xSmall) - // Road map history + // Road map history - constrained width on iPad if settings.showHistory && !state.roundHistory.isEmpty { RoadMapView(results: state.recentResults) + .frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity) .padding(.horizontal) } Spacer(minLength: Design.Spacing.small) - // Mini Baccarat betting table + // Mini Baccarat betting table - constrained width on iPad MiniBaccaratTableView( gameState: state, selectedChip: selectedChip ) + .frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity) .padding(.horizontal, Design.Spacing.medium) Spacer(minLength: Design.Spacing.medium) - // Chip selector - shows higher chips as you win more! + // Chip selector - constrained width on iPad ChipSelectorView( selectedChip: $selectedChip, balance: state.balance, maxBet: state.maxBet ) + .frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity) Spacer(minLength: Design.Spacing.small) - // Action buttons + // Action buttons - constrained width on iPad ActionButtonsView( gameState: state, onDeal: { @@ -105,12 +127,13 @@ struct GameTableView: View { onClear: { state.clearBets() }, onNewRound: { state.newRound() } ) + .frame(maxWidth: isLargeScreen ? maxContentWidth * 0.8 : .infinity) .padding(.horizontal) .padding(.bottom, Design.Spacing.xSmall) } .safeAreaPadding(.bottom) - // Result banner overlay + // Result banner overlay (handles its own iPad sizing) if state.showResultBanner, let result = state.lastResult { ResultBannerView( 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 { GameOverView( roundsPlayed: state.roundHistory.count, @@ -171,6 +194,12 @@ struct GameOverView: View { let onPlayAgain: () -> Void @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) @@ -290,6 +319,7 @@ struct GameOverView: View { ) ) .shadow(color: .red.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusXXLarge) + .frame(maxWidth: maxModalWidth) .padding(.horizontal, Design.Spacing.xxLarge) .scaleEffect(showContent ? Design.Scale.normal : Design.Scale.slightShrink) .opacity(showContent ? 1.0 : 0) @@ -434,29 +464,50 @@ struct CompactHandView: View { // Fixed size: cards have strict visual constraints private let cardWidth: CGFloat = 45 + private let cardHeight: CGFloat = 63 // Standard card aspect ratio ~1.4 private let cardOverlap: CGFloat = -12 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 { - HStack(spacing: cards.isEmpty ? placeholderSpacing : cardOverlap) { - if cards.isEmpty { - // Placeholders - no overlap, just side by side - ForEach(0..<2, id: \.self) { _ in - CardPlaceholderView(width: cardWidth) - } - } else { - ForEach(cards.indices, id: \.self) { index in - let isFaceUp = index < cardsFaceUp.count ? cardsFaceUp[index] : false - CardView( - card: cards[index], - isFaceUp: isFaceUp, - cardWidth: cardWidth - ) - .zIndex(Double(index)) + ZStack { + // Fixed-size container + Color.clear + .frame(width: fixedContainerWidth, height: fixedContainerHeight) + + // Cards content centered in fixed container + HStack(spacing: cards.isEmpty ? placeholderSpacing : cardOverlap) { + if cards.isEmpty { + // Placeholders - no overlap, just side by side + ForEach(0..<2, id: \.self) { _ in + CardPlaceholderView(width: cardWidth) + } + } else { + ForEach(cards.indices, id: \.self) { index in + let isFaceUp = index < cardsFaceUp.count ? cardsFaceUp[index] : false + CardView( + card: cards[index], + isFaceUp: isFaceUp, + cardWidth: cardWidth + ) + .zIndex(Double(index)) + } } } } - .padding(Design.Spacing.xSmall) .background( RoundedRectangle(cornerRadius: Design.CornerRadius.small) .strokeBorder( @@ -692,33 +743,45 @@ struct ActionButtonsView: View { private let iconSize: CGFloat = Design.BaseFontSize.xxLarge + Design.Spacing.xSmall 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 { - HStack(spacing: Design.Spacing.medium) { - if gameState.currentPhase == .betting { - // Clear bets button - icon only at accessibility sizes - clearButton - - // Deal button - icon only at accessibility sizes - dealButton - } else if gameState.currentPhase == .roundComplete && !gameState.showResultBanner { - // New round button - only shown after banner is dismissed - // (The banner itself has a New Round button) - newRoundButton - } else if !gameState.showResultBanner { - // Playing indicator - HStack(spacing: Design.Spacing.xSmall) { - ProgressView() - .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) + ZStack { + // Fixed height container to prevent layout shifts + Color.clear + .frame(height: containerHeight) + + // Content changes with animation + Group { + if gameState.currentPhase == .betting { + // Clear bets button - icon only at accessibility sizes + HStack(spacing: Design.Spacing.medium) { + clearButton + dealButton + } + } else if gameState.currentPhase == .roundComplete && !gameState.showResultBanner { + // New round button - only shown after banner is dismissed + // (The banner itself has a New Round button) + newRoundButton + } else if !gameState.showResultBanner { + // Playing indicator + HStack(spacing: Design.Spacing.xSmall) { + ProgressView() + .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) } } diff --git a/Baccarat/Views/ResultBannerView.swift b/Baccarat/Views/ResultBannerView.swift index cc187fb..8ac6f9f 100644 --- a/Baccarat/Views/ResultBannerView.swift +++ b/Baccarat/Views/ResultBannerView.swift @@ -31,6 +31,13 @@ struct ResultBannerView: View { @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 @@ -185,6 +192,7 @@ struct ResultBannerView: View { } .padding(.horizontal, Design.Spacing.xLarge) .padding(.vertical, Design.Spacing.xxLarge) + .frame(maxWidth: maxBannerWidth) .background( RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge) .fill( @@ -213,6 +221,7 @@ struct ResultBannerView: View { ) ) .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) } @@ -395,6 +404,8 @@ private struct PairBadge: View { /// Confetti particle for celebrations. struct ConfettiPiece: View { let color: Color + let containerSize: CGSize + @State private var position: CGPoint = .zero @State private var rotation: Double = 0 @State private var opacity: Double = 1 @@ -410,14 +421,13 @@ struct ConfettiPiece: View { .position(position) .opacity(opacity) .onAppear { - let screenWidth = 400.0 - let startX = Double.random(in: 0...screenWidth) + let startX = Double.random(in: 0...containerSize.width) 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 + y: containerSize.height + 50 ) rotation = Double.random(in: 360...1080) opacity = 0 @@ -431,11 +441,17 @@ 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) + GeometryReader { geometry in + ZStack { + ForEach(0..<50, id: \.self) { _ in + ConfettiPiece( + color: colors.randomElement() ?? .yellow, + containerSize: geometry.size + ) + } } } + .ignoresSafeArea() .allowsHitTesting(false) .accessibilityHidden(true) } diff --git a/Baccarat/Views/SettingsView.swift b/Baccarat/Views/SettingsView.swift index 4b90753..c3328ba 100644 --- a/Baccarat/Views/SettingsView.swift +++ b/Baccarat/Views/SettingsView.swift @@ -134,6 +134,7 @@ struct SettingsView: View { } } .tint(.yellow) + .padding(.vertical, Design.Spacing.xSmall) if gameState.iCloudEnabled { Divider() @@ -186,6 +187,7 @@ struct SettingsView: View { .foregroundStyle(.white.opacity(Design.Opacity.medium)) } } + .padding(.vertical, Design.Spacing.xSmall) } } @@ -380,6 +382,7 @@ struct SettingsToggle: View { } } .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)) } } + .padding(.vertical, Design.Spacing.xSmall) } }