CasinoGames/CasinoKit/Sources/CasinoKit/Views/Bars/TopBarView.swift

299 lines
9.2 KiB
Swift

//
// TopBarView.swift
// CasinoKit
//
// A reusable top bar for casino games showing balance and toolbar buttons.
//
import SwiftUI
/// A button configuration for the top bar toolbar.
public struct TopBarButton: Identifiable {
public let id = UUID()
/// The SF Symbol icon name.
public let icon: String
/// The accessibility label for VoiceOver.
public let accessibilityLabel: String
/// The action to perform when tapped.
public let action: () -> Void
/// Creates a toolbar button configuration.
/// - Parameters:
/// - icon: SF Symbol name for the button icon.
/// - accessibilityLabel: VoiceOver label for the button.
/// - action: Closure to execute when tapped.
public init(icon: String, accessibilityLabel: String, action: @escaping () -> Void) {
self.icon = icon
self.accessibilityLabel = accessibilityLabel
self.action = action
}
}
/// Optional Sherpa tags for TopBarView elements.
/// Use this to enable walkthrough highlighting of individual top bar elements.
public struct TopBarSherpaTags<Tags: SherpaTags> {
public let balance: Tags?
public let cardsRemaining: Tags?
public let stats: Tags?
public let rules: Tags?
public let settings: Tags?
public init(
balance: Tags? = nil,
cardsRemaining: Tags? = nil,
stats: Tags? = nil,
rules: Tags? = nil,
settings: Tags? = nil
) {
self.balance = balance
self.cardsRemaining = cardsRemaining
self.stats = stats
self.rules = rules
self.settings = settings
}
}
/// A top bar showing balance and customizable toolbar buttons.
public struct TopBarView<Tags: SherpaTags>: View {
/// The current balance to display.
public let balance: Int
/// Optional secondary info (e.g., cards remaining).
public let secondaryInfo: String?
/// Icon for secondary info.
public let secondaryIcon: String?
/// Custom buttons to display at the front of the toolbar (before stats/help/settings).
public let leadingButtons: [TopBarButton]
/// Action when settings is tapped.
public let onSettings: (() -> Void)?
/// Action when help/rules is tapped.
public let onHelp: (() -> Void)?
/// Action when stats is tapped.
public let onStats: (() -> Void)?
/// Optional Sherpa tags for walkthrough highlighting.
public let sherpaTags: TopBarSherpaTags<Tags>?
// MARK: - Font Sizes (fixed for top bar constraints)
private let balanceFontSize: CGFloat = 24
private let dollarFontSize: CGFloat = 14
private let secondaryFontSize: CGFloat = 14
private let iconSize: CGFloat = 20
/// Creates a top bar.
/// - Parameters:
/// - balance: The current balance.
/// - secondaryInfo: Optional secondary info text.
/// - secondaryIcon: Optional SF Symbol for secondary info.
/// - leadingButtons: Custom buttons to add at the front of the toolbar.
/// - onSettings: Settings button action.
/// - onHelp: Help button action.
/// - onStats: Stats button action.
/// - sherpaTags: Optional Sherpa tags for walkthrough highlighting.
public init(
balance: Int,
secondaryInfo: String? = nil,
secondaryIcon: String? = nil,
leadingButtons: [TopBarButton] = [],
onSettings: (() -> Void)? = nil,
onHelp: (() -> Void)? = nil,
onStats: (() -> Void)? = nil,
sherpaTags: TopBarSherpaTags<Tags>? = nil
) {
self.balance = balance
self.secondaryInfo = secondaryInfo
self.secondaryIcon = secondaryIcon
self.leadingButtons = leadingButtons
self.onSettings = onSettings
self.onHelp = onHelp
self.onStats = onStats
self.sherpaTags = sherpaTags
}
public var body: some View {
HStack {
// Balance display
balanceView
Spacer()
// Secondary info (centered)
if let info = secondaryInfo {
secondaryInfoView(info: info)
}
Spacer()
// Toolbar buttons
toolbarButtonsView
}
.padding(.horizontal, CasinoDesign.Spacing.large)
.padding(.vertical, CasinoDesign.Spacing.small)
}
// MARK: - Private Views
@ViewBuilder
private var balanceView: some View {
let view = HStack(spacing: CasinoDesign.Spacing.xxSmall) {
Text("$")
.font(.system(size: dollarFontSize, weight: .bold))
.foregroundStyle(Color.CasinoTopBar.balanceText)
Text(balance.formatted())
.font(.system(size: balanceFontSize, weight: .bold, design: .rounded))
.foregroundStyle(Color.CasinoTopBar.balanceText)
.contentTransition(.numericText())
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Balance", bundle: .module))
.accessibilityValue("$\(balance.formatted())")
if let tag = sherpaTags?.balance {
view.sherpaTag(tag)
} else {
view
}
}
@ViewBuilder
private func secondaryInfoView(info: String) -> some View {
let view = HStack(spacing: CasinoDesign.Spacing.xSmall) {
if let icon = secondaryIcon {
Image(systemName: icon)
}
Text(info)
}
.font(.system(size: secondaryFontSize))
.foregroundStyle(Color.CasinoTopBar.secondaryText)
if let tag = sherpaTags?.cardsRemaining {
view.sherpaTag(tag)
} else {
view
}
}
@ViewBuilder
private var toolbarButtonsView: some View {
HStack(spacing: CasinoDesign.Spacing.medium) {
// Custom leading buttons (game-specific)
ForEach(leadingButtons) { button in
ToolbarButton(icon: button.icon, action: button.action)
.accessibilityLabel(button.accessibilityLabel)
}
if let onStats = onStats {
let statsButton = ToolbarButton(icon: "chart.bar.fill", action: onStats)
.accessibilityLabel(String(localized: "Statistics", bundle: .module))
if let tag = sherpaTags?.stats {
statsButton.sherpaTag(tag)
} else {
statsButton
}
}
if let onHelp = onHelp {
let helpButton = ToolbarButton(icon: "info.circle", action: onHelp)
.accessibilityLabel(String(localized: "Rules", bundle: .module))
if let tag = sherpaTags?.rules {
helpButton.sherpaTag(tag)
} else {
helpButton
}
}
if let onSettings = onSettings {
let settingsButton = ToolbarButton(icon: "gearshape.fill", action: onSettings)
.accessibilityLabel(String(localized: "Settings", bundle: .module))
if let tag = sherpaTags?.settings {
settingsButton.sherpaTag(tag)
} else {
settingsButton
}
}
}
}
}
/// Convenience initializer for TopBarView without Sherpa tags.
public extension TopBarView where Tags == NoSherpaTags {
/// Creates a top bar without Sherpa walkthrough support.
init(
balance: Int,
secondaryInfo: String? = nil,
secondaryIcon: String? = nil,
leadingButtons: [TopBarButton] = [],
onSettings: (() -> Void)? = nil,
onHelp: (() -> Void)? = nil,
onStats: (() -> Void)? = nil
) {
self.balance = balance
self.secondaryInfo = secondaryInfo
self.secondaryIcon = secondaryIcon
self.leadingButtons = leadingButtons
self.onSettings = onSettings
self.onHelp = onHelp
self.onStats = onStats
self.sherpaTags = nil
}
}
/// A placeholder SherpaTags type for when no walkthrough is needed.
public enum NoSherpaTags: SherpaTags {
public func makeCallout() -> Callout {
.text("")
}
}
/// A single toolbar button.
struct ToolbarButton: View {
let icon: String
let action: () -> Void
private let iconSize: CGFloat = 20
var body: some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: iconSize))
.foregroundStyle(Color.CasinoTopBar.iconButton)
}
}
}
#Preview {
ZStack {
Color.CasinoTable.felt.ignoresSafeArea()
VStack {
TopBarView<NoSherpaTags>(
balance: 10_500,
secondaryInfo: "411",
secondaryIcon: "rectangle.portrait.on.rectangle.portrait.fill",
leadingButtons: [
TopBarButton(icon: "arrow.counterclockwise", accessibilityLabel: "Reset") {}
],
onSettings: {},
onHelp: {},
onStats: {}
)
Spacer()
}
}
}