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

This commit is contained in:
Matt Bruce 2025-12-24 12:19:59 -06:00
parent 1dc64ebf69
commit 2cd2946a80
6 changed files with 4638 additions and 4606 deletions

View File

@ -53,6 +53,9 @@ final class GameState {
/// Whether to show side bet toast notifications. /// Whether to show side bet toast notifications.
var showSideBetToasts: Bool = false var showSideBetToasts: Bool = false
/// Whether to show the gameplay hint toast.
var showHintToast: Bool = false
/// Whether a reshuffle notification should be shown. /// Whether a reshuffle notification should be shown.
var showReshuffleNotification: Bool = false var showReshuffleNotification: Bool = false
@ -787,7 +790,7 @@ final class GameState {
// Auto-hide toasts after delay // Auto-hide toasts after delay
Task { Task {
try? await Task.sleep(for: .seconds(3)) try? await Task.sleep(for: Design.Toast.duration)
showSideBetToasts = false showSideBetToasts = false
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -109,6 +109,13 @@ enum Design {
/// Bounce for side bet toast animations /// Bounce for side bet toast animations
static let toastBounce: Double = 0.4 static let toastBounce: Double = 0.4
} }
// MARK: - Toast Configuration
enum Toast {
/// Duration all toasts stay visible (in seconds).
static let duration: Duration = .seconds(2)
}
} }
// MARK: - Blackjack App Colors // MARK: - Blackjack App Colors

View File

@ -71,6 +71,31 @@ struct GameTableView: View {
// Use global debug flag from Design constants // Use global debug flag from Design constants
private var showDebugBorders: Bool { Design.showDebugBorders } private var showDebugBorders: Bool { Design.showDebugBorders }
// MARK: - Toolbar Buttons
/// Returns hint toolbar button when a hint is available.
private func hintToolbarButtons(for state: GameState) -> [TopBarButton] {
guard state.currentHint != nil else { return [] }
return [
TopBarButton(
icon: "lightbulb.fill",
accessibilityLabel: String(localized: "Show Hint")
) {
// Show the toast with animation
withAnimation(.spring(duration: Design.Animation.springDuration)) {
state.showHintToast = true
}
// Auto-dismiss after delay (same as auto-show behavior)
Task { @MainActor in
try? await Task.sleep(for: Design.Toast.duration)
withAnimation(.spring(duration: Design.Animation.springDuration)) {
state.showHintToast = false
}
}
}
]
}
// MARK: - Main Game View // MARK: - Main Game View
@ViewBuilder @ViewBuilder
@ -97,6 +122,7 @@ struct GameTableView: View {
balance: state.balance, balance: state.balance,
secondaryInfo: settings.showCardsRemaining ? "\(state.engine.cardsRemaining)" : nil, secondaryInfo: settings.showCardsRemaining ? "\(state.engine.cardsRemaining)" : nil,
secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil, secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil,
leadingButtons: hintToolbarButtons(for: state),
onSettings: { showSettings = true }, onSettings: { showSettings = true },
onHelp: { showRules = true }, onHelp: { showRules = true },
onStats: { showStats = true } onStats: { showStats = true }
@ -143,7 +169,7 @@ struct GameTableView: View {
.padding(.bottom, Design.Spacing.small) .padding(.bottom, Design.Spacing.small)
.debugBorder(showDebugBorders, color: .blue, label: "ActionBtns") .debugBorder(showDebugBorders, color: .blue, label: "ActionBtns")
} }
.frame(maxWidth: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.zIndex(1) .zIndex(1)
.onChange(of: state.currentPhase) { oldPhase, newPhase in .onChange(of: state.currentPhase) { oldPhase, newPhase in
Design.debugLog("🔄 Phase changed: \(oldPhase)\(newPhase)") Design.debugLog("🔄 Phase changed: \(oldPhase)\(newPhase)")
@ -208,6 +234,7 @@ struct GameTableView: View {
.allowsHitTesting(true) .allowsHitTesting(true)
.zIndex(100) .zIndex(100)
} }
} }
.onChange(of: state.playerHands.count) { oldCount, newCount in .onChange(of: state.playerHands.count) { oldCount, newCount in
Design.debugLog("👥 Player hands count: \(oldCount)\(newCount)") Design.debugLog("👥 Player hands count: \(oldCount)\(newCount)")

View File

@ -31,7 +31,6 @@ struct BlackjackTableView: View {
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.medium @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.medium
@ScaledMetric(relativeTo: .title) private var valueFontSize: CGFloat = Design.BaseFontSize.xLarge @ScaledMetric(relativeTo: .title) private var valueFontSize: CGFloat = Design.BaseFontSize.xLarge
@ScaledMetric(relativeTo: .caption) private var hintFontSize: CGFloat = Design.BaseFontSize.small
// MARK: - Dynamic Card Sizing // MARK: - Dynamic Card Sizing
@ -48,7 +47,7 @@ struct BlackjackTableView: View {
/// Card width based on full screen height (stable - doesn't change with content) /// Card width based on full screen height (stable - doesn't change with content)
private var cardWidth: CGFloat { private var cardWidth: CGFloat {
let maxDimension = screenHeight let maxDimension = screenHeight
let percentage: CGFloat = 0.15 // ~10% of screen let percentage: CGFloat = 0.18 // ~10% of screen
return maxDimension * percentage return maxDimension * percentage
} }
@ -58,32 +57,12 @@ struct BlackjackTableView: View {
cardWidth * -0.55 cardWidth * -0.55
} }
/// Fixed height for the hint area to prevent layout shifts
private let hintAreaHeight: CGFloat = 44
// Use global debug flag from Design constants // Use global debug flag from Design constants
private var showDebugBorders: Bool { Design.showDebugBorders } private var showDebugBorders: Bool { Design.showDebugBorders }
/// Dynamic spacer height based on screen size.
/// Formula: spacing = clamp((screenHeight - baseline) * scale, min, max)
/// This produces smooth scaling across all device sizes:
/// - iPhone SE (~667pt): ~20pt
/// - iPhone Pro Max (~932pt): ~76pt
/// - iPad Mini (~1024pt): ~95pt
/// - iPad Pro 12.9" (~1366pt): ~150pt (capped)
private var dealerPlayerSpacing: CGFloat {
let baseline: CGFloat = 550 // Below this, use minimum
let scale: CGFloat = 0.18 // 20% of height above baseline
let minSpacing: CGFloat = 10 // Floor for smallest screens
let maxSpacing: CGFloat = 150 // Ceiling for largest screens
let calculated = (screenHeight - baseline) * scale
return min(maxSpacing, max(minSpacing, calculated))
}
var body: some View { var body: some View {
VStack(spacing: Design.Spacing.small) { VStack(spacing: 0) {
// Dealer area // Dealer area - pushed to top
DealerHandView( DealerHandView(
hand: state.dealerHand, hand: state.dealerHand,
showHoleCard: state.shouldShowDealerHoleCard, showHoleCard: state.shouldShowDealerHoleCard,
@ -93,33 +72,23 @@ struct BlackjackTableView: View {
) )
.debugBorder(showDebugBorders, color: .red, label: "Dealer") .debugBorder(showDebugBorders, color: .red, label: "Dealer")
// Space between dealer and player - contains card count if enabled // Top spacer pushes dealer up
Spacer(minLength: Design.Spacing.small)
.debugBorder(showDebugBorders, color: .yellow, label: "TopSpacer")
// Card count view centered between dealer and player
if showCardCount { if showCardCount {
// Card count view fills the space between hands CardCountView(
VStack { runningCount: state.engine.runningCount,
// Top spacer for larger screens trueCount: state.engine.trueCount
if screenHeight > 700 { )
Spacer(minLength: Design.Spacing.small)
}
CardCountView(
runningCount: state.engine.runningCount,
trueCount: state.engine.trueCount
)
// Bottom spacer for larger screens
if screenHeight > 700 {
Spacer(minLength: Design.Spacing.small)
}
}
.frame(minHeight: dealerPlayerSpacing)
.debugBorder(showDebugBorders, color: .mint, label: "CardCount") .debugBorder(showDebugBorders, color: .mint, label: "CardCount")
} else {
// No card count - just use flexible spacer
Spacer(minLength: dealerPlayerSpacing)
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer \(Int(dealerPlayerSpacing))")
} }
// Bottom spacer pushes player down
Spacer(minLength: Design.Spacing.small)
.debugBorder(showDebugBorders, color: .yellow, label: "BottomSpacer")
// Player hands area - only show when there are cards dealt // Player hands area - only show when there are cards dealt
if state.playerHands.first?.cards.isEmpty == false { if state.playerHands.first?.cards.isEmpty == false {
ZStack { ZStack {
@ -162,6 +131,33 @@ struct BlackjackTableView: View {
.padding(.horizontal, Design.Spacing.small) .padding(.horizontal, Design.Spacing.small)
} }
} }
.overlay {
// Hint toast overlaying player hands (auto-dismisses)
if state.showHintToast, let hint = state.currentHint {
HintView(hint: hint)
.transition(.scale.combined(with: .opacity))
}
}
.onChange(of: state.currentHint) { oldHint, newHint in
// Show toast when a new hint appears
if let hint = newHint, hint != oldHint {
withAnimation(.spring(duration: Design.Animation.springDuration)) {
state.showHintToast = true
}
// Auto-dismiss after delay
Task { @MainActor in
try? await Task.sleep(for: Design.Toast.duration)
withAnimation(.spring(duration: Design.Animation.springDuration)) {
state.showHintToast = false
}
}
} else if newHint == nil {
// Hide immediately when no hint
withAnimation(.spring(duration: Design.Animation.springDuration)) {
state.showHintToast = false
}
}
}
.padding(.bottom, 5) .padding(.bottom, 5)
.transition(.opacity) .transition(.opacity)
.debugBorder(showDebugBorders, color: .green, label: "Player") .debugBorder(showDebugBorders, color: .green, label: "Player")
@ -183,22 +179,12 @@ struct BlackjackTableView: View {
if let hint = state.bettingHint { if let hint = state.bettingHint {
BettingHintView(hint: hint, trueCount: state.engine.trueCount) BettingHintView(hint: hint, trueCount: state.engine.trueCount)
.transition(.opacity) .transition(.opacity)
.padding(.vertical, 10)
.debugBorder(showDebugBorders, color: .purple, label: "BetHint") .debugBorder(showDebugBorders, color: .purple, label: "BetHint")
} }
} else {
// Fixed-height hint area to prevent layout shifts during player turn
ZStack {
if let hint = state.currentHint {
HintView(hint: hint)
.transition(.opacity)
}
}
.frame(height: hintAreaHeight)
.debugBorder(showDebugBorders, color: .orange, label: "HintArea")
} }
} }
.padding(.horizontal, Design.Spacing.large) .padding(.horizontal, Design.Spacing.large)
.padding(.vertical, Design.Spacing.medium)
.debugBorder(showDebugBorders, color: .white, label: "TableView") .debugBorder(showDebugBorders, color: .white, label: "TableView")
.animation(.spring(duration: Design.Animation.springDuration), value: state.currentPhase) .animation(.spring(duration: Design.Animation.springDuration), value: state.currentPhase)
} }

View File

@ -26,7 +26,7 @@ struct HintView: View {
.foregroundStyle(.yellow) .foregroundStyle(.yellow)
Text(String(localized: "Hint: \(hint)")) Text(String(localized: "Hint: \(hint)"))
.font(.system(size: fontSize, weight: .medium)) .font(.system(size: fontSize, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.strong)) .foregroundStyle(.white)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.comfortable) .minimumScaleFactor(Design.MinScaleFactor.comfortable)
} }
@ -34,8 +34,13 @@ struct HintView: View {
.padding(.vertical, paddingV) .padding(.vertical, paddingV)
.background( .background(
Capsule() Capsule()
.fill(Color.black.opacity(Design.Opacity.light)) .fill(Color.black.opacity(Design.Opacity.heavy))
.overlay(
Capsule()
.strokeBorder(Color.yellow.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin)
)
) )
.shadow(color: .black.opacity(Design.Opacity.medium), radius: Design.Shadow.radiusMedium)
.accessibilityElement(children: .ignore) .accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Hint")) .accessibilityLabel(String(localized: "Hint"))
.accessibilityValue(hint) .accessibilityValue(hint)