Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
754654665c
commit
544b2b9a86
@ -385,7 +385,9 @@ final class GameState {
|
||||
roundHistory.append(RoundResult(
|
||||
result: result,
|
||||
playerValue: playerHandValue,
|
||||
bankerValue: bankerHandValue
|
||||
bankerValue: bankerHandValue,
|
||||
playerPair: playerHadPair,
|
||||
bankerPair: bankerHadPair
|
||||
))
|
||||
|
||||
// Show result banner - stays until user taps New Round
|
||||
|
||||
@ -55,12 +55,32 @@ struct RoundResult: Identifiable {
|
||||
let result: GameResult
|
||||
let playerValue: Int
|
||||
let bankerValue: Int
|
||||
let playerPair: Bool
|
||||
let bankerPair: Bool
|
||||
let timestamp: Date
|
||||
|
||||
init(result: GameResult, playerValue: Int, bankerValue: Int) {
|
||||
/// Whether either hand was a natural (8 or 9).
|
||||
var isNatural: Bool {
|
||||
playerValue >= 8 || bankerValue >= 8
|
||||
}
|
||||
|
||||
/// Whether any pair occurred.
|
||||
var hasPair: Bool {
|
||||
playerPair || bankerPair
|
||||
}
|
||||
|
||||
init(
|
||||
result: GameResult,
|
||||
playerValue: Int,
|
||||
bankerValue: Int,
|
||||
playerPair: Bool = false,
|
||||
bankerPair: Bool = false
|
||||
) {
|
||||
self.result = result
|
||||
self.playerValue = playerValue
|
||||
self.bankerValue = bankerValue
|
||||
self.playerPair = playerPair
|
||||
self.bankerPair = bankerPair
|
||||
self.timestamp = .now
|
||||
}
|
||||
}
|
||||
|
||||
@ -166,6 +166,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"B Pair" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "B Pair"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Par B"
|
||||
}
|
||||
},
|
||||
"es-MX" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Par B"
|
||||
}
|
||||
},
|
||||
"es-US" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Par B"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Paire B"
|
||||
}
|
||||
},
|
||||
"fr-CA" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Paire B"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"BACCARAT" : {
|
||||
|
||||
},
|
||||
@ -213,6 +253,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Banker" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Banker"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Banca"
|
||||
}
|
||||
},
|
||||
"es-MX" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Banca"
|
||||
}
|
||||
},
|
||||
"es-US" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Banca"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Banquier"
|
||||
}
|
||||
},
|
||||
"fr-CA" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Banquier"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"BANKER" : {
|
||||
"comment" : "A label displayed above the banker's hand in the baccarat table view.",
|
||||
"localizations" : {
|
||||
@ -671,6 +751,46 @@
|
||||
"comment" : "A section header for information about winning with a natural hand.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Naturals" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Naturals"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Naturales"
|
||||
}
|
||||
},
|
||||
"es-MX" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Naturales"
|
||||
}
|
||||
},
|
||||
"es-US" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Naturales"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Naturels"
|
||||
}
|
||||
},
|
||||
"fr-CA" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Naturels"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"New Round" : {
|
||||
"comment" : "The label of a button that starts a new round of the game.",
|
||||
"localizations" : {
|
||||
@ -714,6 +834,86 @@
|
||||
"comment" : "Summary text for a road map view when there is no history.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No rounds played yet" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "No rounds played yet"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Aún no hay rondas jugadas"
|
||||
}
|
||||
},
|
||||
"es-MX" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Aún no hay rondas jugadas"
|
||||
}
|
||||
},
|
||||
"es-US" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Aún no hay rondas jugadas"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Aucune partie jouée"
|
||||
}
|
||||
},
|
||||
"fr-CA" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Aucune partie jouée"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"P Pair" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "P Pair"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Par J"
|
||||
}
|
||||
},
|
||||
"es-MX" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Par J"
|
||||
}
|
||||
},
|
||||
"es-US" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Par J"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Paire J"
|
||||
}
|
||||
},
|
||||
"fr-CA" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Paire J"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Pair Bonus" : {
|
||||
"comment" : "Title of the page explaining the pair bonus in Baccarat.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -872,6 +1072,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Player" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Player"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Jugador"
|
||||
}
|
||||
},
|
||||
"es-MX" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Jugador"
|
||||
}
|
||||
},
|
||||
"es-US" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Jugador"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Joueur"
|
||||
}
|
||||
},
|
||||
"fr-CA" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Joueur"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PLAYER" : {
|
||||
"comment" : "The label for the player's hand in the cards display area.",
|
||||
"localizations" : {
|
||||
@ -1026,6 +1266,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Rounds" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Rounds"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Rondas"
|
||||
}
|
||||
},
|
||||
"es-MX" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Rondas"
|
||||
}
|
||||
},
|
||||
"es-US" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Rondas"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Parties"
|
||||
}
|
||||
},
|
||||
"fr-CA" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Parties"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Rounds Played" : {
|
||||
"comment" : "A label displayed next to the number of rounds played in the game over screen.",
|
||||
"localizations" : {
|
||||
@ -1096,6 +1376,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Statistics" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Statistics"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Estadísticas"
|
||||
}
|
||||
},
|
||||
"es-MX" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Estadísticas"
|
||||
}
|
||||
},
|
||||
"es-US" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Estadísticas"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Statistiques"
|
||||
}
|
||||
},
|
||||
"fr-CA" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Statistiques"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tableLimitsFormat" : {
|
||||
"comment" : "Format string for table limits display. First argument is min bet, second is max bet.",
|
||||
"extractionState" : "stale",
|
||||
@ -1140,6 +1460,46 @@
|
||||
},
|
||||
"Third Card Rules" : {
|
||||
|
||||
},
|
||||
"Tie" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Tie"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Empate"
|
||||
}
|
||||
},
|
||||
"es-MX" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Empate"
|
||||
}
|
||||
},
|
||||
"es-US" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Empate"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Égalité"
|
||||
}
|
||||
},
|
||||
"fr-CA" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Égalité"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TIE" : {
|
||||
"comment" : "The text displayed in the TIE betting zone.",
|
||||
@ -1226,6 +1586,10 @@
|
||||
"comment" : "A label displayed next to the total winnings in the result banner.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"View detailed game statistics" : {
|
||||
"comment" : "A hint that appears when hovering over the \"Statistics\" button, explaining its function.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"WIN" : {
|
||||
"comment" : "The text that appears as a badge when a player wins a hand in baccarat.",
|
||||
"localizations" : {
|
||||
|
||||
@ -15,6 +15,7 @@ struct GameTableView: View {
|
||||
@State private var selectedChip: ChipDenomination = .hundred
|
||||
@State private var showSettings = false
|
||||
@State private var showRules = false
|
||||
@State private var showStats = false
|
||||
|
||||
private var state: GameState {
|
||||
gameState ?? GameState(settings: settings)
|
||||
@ -46,7 +47,8 @@ struct GameTableView: View {
|
||||
showCardsRemaining: settings.showCardsRemaining,
|
||||
onReset: { state.resetGame() },
|
||||
onSettings: { showSettings = true },
|
||||
onHelp: { showRules = true }
|
||||
onHelp: { showRules = true },
|
||||
onStats: { showStats = true }
|
||||
)
|
||||
|
||||
Spacer(minLength: Design.Spacing.xSmall)
|
||||
@ -149,6 +151,9 @@ struct GameTableView: View {
|
||||
.fullScreenCover(isPresented: $showRules) {
|
||||
RulesHelpView()
|
||||
}
|
||||
.sheet(isPresented: $showStats) {
|
||||
StatisticsSheetView(results: state.roundHistory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -541,6 +546,7 @@ struct TopBarView: View {
|
||||
let onReset: () -> Void
|
||||
let onSettings: () -> Void
|
||||
let onHelp: () -> Void
|
||||
let onStats: () -> Void
|
||||
|
||||
// MARK: - Environment
|
||||
|
||||
@ -605,6 +611,18 @@ struct TopBarView: View {
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Statistics button
|
||||
Button("Statistics", systemImage: "chart.bar.fill", action: onStats)
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.system(size: buttonFontSize))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.accent))
|
||||
.padding(Design.Spacing.small)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(Color.black.opacity(Design.Opacity.overlay))
|
||||
)
|
||||
.accessibilityHint(String(localized: "View detailed game statistics"))
|
||||
|
||||
// Help/Rules button
|
||||
Button("Help", systemImage: "info.circle.fill", action: onHelp)
|
||||
.labelStyle(.iconOnly)
|
||||
|
||||
@ -42,7 +42,12 @@ struct RoadMapView: View {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: Design.Spacing.xSmall) {
|
||||
ForEach(results) { result in
|
||||
RoadDot(result: result.result, dotSize: dotSize)
|
||||
RoadDot(
|
||||
result: result.result,
|
||||
dotSize: dotSize,
|
||||
hasPair: result.hasPair,
|
||||
isNatural: result.isNatural
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, Design.Spacing.xSmall)
|
||||
@ -65,6 +70,12 @@ struct RoadMapView: View {
|
||||
struct RoadDot: View {
|
||||
let result: GameResult
|
||||
let dotSize: CGFloat
|
||||
var hasPair: Bool = false
|
||||
var isNatural: Bool = false
|
||||
|
||||
// MARK: - Layout Constants
|
||||
|
||||
private var markerSize: CGFloat { dotSize * 0.3 }
|
||||
|
||||
// MARK: - Scaled Fonts (Dynamic Type)
|
||||
|
||||
@ -99,6 +110,26 @@ struct RoadDot: View {
|
||||
Text(label)
|
||||
.font(.system(size: labelFontSize, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
// Pair marker (bottom-left corner)
|
||||
if hasPair {
|
||||
Circle()
|
||||
.fill(Color.yellow)
|
||||
.frame(width: markerSize, height: markerSize)
|
||||
.overlay(
|
||||
Circle()
|
||||
.strokeBorder(Color.white, lineWidth: Design.LineWidth.thin)
|
||||
)
|
||||
.offset(x: -dotSize * 0.35, y: dotSize * 0.35)
|
||||
}
|
||||
|
||||
// Natural marker (top-right corner - star shape)
|
||||
if isNatural {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.system(size: markerSize))
|
||||
.foregroundStyle(.yellow)
|
||||
.offset(x: dotSize * 0.35, y: -dotSize * 0.35)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -109,11 +140,11 @@ struct RoadDot: View {
|
||||
.ignoresSafeArea()
|
||||
|
||||
RoadMapView(results: [
|
||||
RoundResult(result: .playerWins, playerValue: 8, bankerValue: 6),
|
||||
RoundResult(result: .playerWins, playerValue: 8, bankerValue: 6, playerPair: true),
|
||||
RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 7),
|
||||
RoundResult(result: .tie, playerValue: 5, bankerValue: 5),
|
||||
RoundResult(result: .tie, playerValue: 5, bankerValue: 5, bankerPair: true),
|
||||
RoundResult(result: .playerWins, playerValue: 9, bankerValue: 3),
|
||||
RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 8)
|
||||
RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 8, playerPair: true, bankerPair: true)
|
||||
])
|
||||
.padding()
|
||||
}
|
||||
|
||||
@ -42,8 +42,8 @@ struct RulesHelpView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background
|
||||
Color.black.opacity(Design.Opacity.almostFull)
|
||||
// Background - same as other sheets
|
||||
Color.Settings.background
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
@ -107,7 +107,7 @@ struct RulesHelpView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: contentCornerRadius)
|
||||
.fill(Color(red: 0.15, green: 0.35, blue: 0.55))
|
||||
.fill(Color.white.opacity(Design.Opacity.verySubtle))
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: contentCornerRadius))
|
||||
}
|
||||
|
||||
@ -17,158 +17,102 @@ struct SettingsView: View {
|
||||
@State private var hasChanges = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
// Background
|
||||
Color.Settings.background
|
||||
.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: Design.Spacing.xxLarge) {
|
||||
// Table Limits Section (First!)
|
||||
SettingsSection(title: "TABLE LIMITS", icon: "banknote") {
|
||||
TableLimitsPicker(selection: $settings.tableLimits)
|
||||
.onChange(of: settings.tableLimits) { _, _ in
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
// Deck Settings Section
|
||||
SettingsSection(title: "DECK SETTINGS", icon: "rectangle.portrait.on.rectangle.portrait") {
|
||||
DeckCountPicker(selection: $settings.deckCount)
|
||||
.onChange(of: settings.deckCount) { _, _ in
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
// Starting Balance Section
|
||||
SettingsSection(title: "STARTING BALANCE", icon: "dollarsign.circle") {
|
||||
BalancePicker(balance: $settings.startingBalance)
|
||||
.onChange(of: settings.startingBalance) { _, _ in
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
// Display Settings Section
|
||||
SettingsSection(title: "DISPLAY", icon: "eye") {
|
||||
SettingsToggle(
|
||||
title: "Show Cards Remaining",
|
||||
subtitle: "Display deck counter at top",
|
||||
isOn: $settings.showCardsRemaining
|
||||
)
|
||||
|
||||
Divider()
|
||||
.background(Color.white.opacity(Design.Opacity.subtle))
|
||||
|
||||
SettingsToggle(
|
||||
title: "Show History",
|
||||
subtitle: "Display result road map",
|
||||
isOn: $settings.showHistory
|
||||
)
|
||||
}
|
||||
|
||||
// Animation Settings Section
|
||||
SettingsSection(title: "ANIMATIONS", icon: "sparkles") {
|
||||
SettingsToggle(
|
||||
title: "Card Animations",
|
||||
subtitle: "Animate dealing and flipping",
|
||||
isOn: $settings.showAnimations
|
||||
)
|
||||
|
||||
if settings.showAnimations {
|
||||
Divider()
|
||||
.background(Color.white.opacity(Design.Opacity.subtle))
|
||||
|
||||
SpeedPicker(speed: $settings.dealingSpeed)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset Button
|
||||
Button {
|
||||
settings.resetToDefaults()
|
||||
SheetContainerView(
|
||||
title: String(localized: "Settings"),
|
||||
content: {
|
||||
// Table Limits Section (First!)
|
||||
SheetSection(title: "TABLE LIMITS", icon: "banknote") {
|
||||
TableLimitsPicker(selection: $settings.tableLimits)
|
||||
.onChange(of: settings.tableLimits) { _, _ in
|
||||
hasChanges = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
Text("Reset to Defaults")
|
||||
}
|
||||
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
|
||||
.foregroundStyle(.red.opacity(Design.Opacity.heavy))
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||
.fill(Color.red.opacity(Design.Opacity.subtle))
|
||||
)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, Design.Spacing.small)
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarBackground(Color.Settings.background, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") {
|
||||
settings.load() // Revert changes
|
||||
dismiss()
|
||||
}
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
settings.save()
|
||||
if hasChanges {
|
||||
onApplyChanges()
|
||||
// Deck Settings Section
|
||||
SheetSection(title: "DECK SETTINGS", icon: "rectangle.portrait.on.rectangle.portrait") {
|
||||
DeckCountPicker(selection: $settings.deckCount)
|
||||
.onChange(of: settings.deckCount) { _, _ in
|
||||
hasChanges = true
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
.bold()
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A settings section with a title and content.
|
||||
struct SettingsSection<Content: View>: View {
|
||||
let title: String
|
||||
let icon: String
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||
// Header
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: Design.BaseFontSize.body, weight: .semibold))
|
||||
.foregroundStyle(.yellow.opacity(Design.Opacity.heavy))
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: Design.BaseFontSize.body, weight: .bold, design: .rounded))
|
||||
.tracking(1)
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.accent))
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.xSmall)
|
||||
|
||||
// Content card
|
||||
VStack(spacing: 0) {
|
||||
content
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||
.fill(Color.white.opacity(Design.Opacity.verySubtle))
|
||||
)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
// Starting Balance Section
|
||||
SheetSection(title: "STARTING BALANCE", icon: "dollarsign.circle") {
|
||||
BalancePicker(balance: $settings.startingBalance)
|
||||
.onChange(of: settings.startingBalance) { _, _ in
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
// Display Settings Section
|
||||
SheetSection(title: "DISPLAY", icon: "eye") {
|
||||
SettingsToggle(
|
||||
title: "Show Cards Remaining",
|
||||
subtitle: "Display deck counter at top",
|
||||
isOn: $settings.showCardsRemaining
|
||||
)
|
||||
|
||||
Divider()
|
||||
.background(Color.white.opacity(Design.Opacity.subtle))
|
||||
|
||||
SettingsToggle(
|
||||
title: "Show History",
|
||||
subtitle: "Display result road map",
|
||||
isOn: $settings.showHistory
|
||||
)
|
||||
}
|
||||
|
||||
// Animation Settings Section
|
||||
SheetSection(title: "ANIMATIONS", icon: "sparkles") {
|
||||
SettingsToggle(
|
||||
title: "Card Animations",
|
||||
subtitle: "Animate dealing and flipping",
|
||||
isOn: $settings.showAnimations
|
||||
)
|
||||
|
||||
if settings.showAnimations {
|
||||
Divider()
|
||||
.background(Color.white.opacity(Design.Opacity.subtle))
|
||||
|
||||
SpeedPicker(speed: $settings.dealingSpeed)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset Button
|
||||
Button {
|
||||
settings.resetToDefaults()
|
||||
hasChanges = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
Text("Reset to Defaults")
|
||||
}
|
||||
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
|
||||
.foregroundStyle(.red.opacity(Design.Opacity.heavy))
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||
.fill(Color.red.opacity(Design.Opacity.subtle))
|
||||
)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, Design.Spacing.small)
|
||||
},
|
||||
onCancel: {
|
||||
settings.load() // Revert changes
|
||||
dismiss()
|
||||
},
|
||||
onDone: {
|
||||
settings.save()
|
||||
if hasChanges {
|
||||
onApplyChanges()
|
||||
}
|
||||
dismiss()
|
||||
},
|
||||
doneButtonText: String(localized: "Done"),
|
||||
cancelButtonText: String(localized: "Cancel")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
441
Baccarat/Views/StatisticsSheetView.swift
Normal file
441
Baccarat/Views/StatisticsSheetView.swift
Normal file
@ -0,0 +1,441 @@
|
||||
//
|
||||
// StatisticsSheetView.swift
|
||||
// Baccarat
|
||||
//
|
||||
// Detailed statistics and scoreboard view.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CasinoKit
|
||||
|
||||
/// A sheet that displays detailed game statistics and Big Road scoreboard.
|
||||
struct StatisticsSheetView: View {
|
||||
let results: [RoundResult]
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
// MARK: - Computed Statistics
|
||||
|
||||
private var totalRounds: Int { results.count }
|
||||
|
||||
private var playerWins: Int {
|
||||
results.filter { $0.result == .playerWins }.count
|
||||
}
|
||||
|
||||
private var bankerWins: Int {
|
||||
results.filter { $0.result == .bankerWins }.count
|
||||
}
|
||||
|
||||
private var tieCount: Int {
|
||||
results.filter { $0.result == .tie }.count
|
||||
}
|
||||
|
||||
private var playerPairs: Int {
|
||||
results.filter { $0.playerPair }.count
|
||||
}
|
||||
|
||||
private var bankerPairs: Int {
|
||||
results.filter { $0.bankerPair }.count
|
||||
}
|
||||
|
||||
private var naturals: Int {
|
||||
results.filter { $0.isNatural }.count
|
||||
}
|
||||
|
||||
private func percentage(_ count: Int) -> String {
|
||||
guard totalRounds > 0 else { return "0%" }
|
||||
let pct = Double(count) / Double(totalRounds) * 100
|
||||
return String(format: "%.0f%%", pct)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
SheetContainerView(
|
||||
title: String(localized: "Statistics"),
|
||||
content: {
|
||||
// Summary stats
|
||||
summarySection
|
||||
|
||||
// Win distribution
|
||||
winDistributionSection
|
||||
|
||||
// Side bet frequency
|
||||
sideBetSection
|
||||
|
||||
// Big Road display
|
||||
bigRoadSection
|
||||
},
|
||||
onDone: {
|
||||
dismiss()
|
||||
},
|
||||
doneButtonText: String(localized: "Done")
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Summary Section
|
||||
|
||||
private var summarySection: some View {
|
||||
SheetSection(title: "SESSION SUMMARY", icon: "chart.pie.fill") {
|
||||
HStack(spacing: Design.Spacing.xLarge) {
|
||||
StatBox(
|
||||
value: "\(totalRounds)",
|
||||
label: String(localized: "Rounds"),
|
||||
color: .white
|
||||
)
|
||||
|
||||
StatBox(
|
||||
value: "\(naturals)",
|
||||
label: String(localized: "Naturals"),
|
||||
color: .yellow
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Win Distribution Section
|
||||
|
||||
private var winDistributionSection: some View {
|
||||
SheetSection(title: "WIN DISTRIBUTION", icon: "trophy.fill") {
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
WinStatView(
|
||||
title: String(localized: "Player"),
|
||||
count: playerWins,
|
||||
percentage: percentage(playerWins),
|
||||
color: .blue
|
||||
)
|
||||
|
||||
WinStatView(
|
||||
title: String(localized: "Tie"),
|
||||
count: tieCount,
|
||||
percentage: percentage(tieCount),
|
||||
color: .green
|
||||
)
|
||||
|
||||
WinStatView(
|
||||
title: String(localized: "Banker"),
|
||||
count: bankerWins,
|
||||
percentage: percentage(bankerWins),
|
||||
color: .red
|
||||
)
|
||||
}
|
||||
|
||||
// Win bar visualization
|
||||
if totalRounds > 0 {
|
||||
WinDistributionBar(
|
||||
playerWins: playerWins,
|
||||
tieCount: tieCount,
|
||||
bankerWins: bankerWins
|
||||
)
|
||||
.frame(height: Design.Spacing.large)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Side Bet Section
|
||||
|
||||
private var sideBetSection: some View {
|
||||
SheetSection(title: "SIDE BET FREQUENCY", icon: "sparkles") {
|
||||
HStack(spacing: Design.Spacing.xLarge) {
|
||||
PairStatView(
|
||||
title: String(localized: "P Pair"),
|
||||
count: playerPairs,
|
||||
percentage: percentage(playerPairs),
|
||||
color: .blue
|
||||
)
|
||||
|
||||
PairStatView(
|
||||
title: String(localized: "B Pair"),
|
||||
count: bankerPairs,
|
||||
percentage: percentage(bankerPairs),
|
||||
color: .red
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Big Road Section
|
||||
|
||||
private var bigRoadSection: some View {
|
||||
SheetSection(title: "BIG ROAD", icon: "chart.bar.xaxis") {
|
||||
if results.isEmpty {
|
||||
Text(String(localized: "No rounds played yet"))
|
||||
.font(.system(size: Design.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.secondary))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Design.Spacing.xLarge)
|
||||
} else {
|
||||
BigRoadView(results: results)
|
||||
.frame(height: Design.Size.bigRoadHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Views
|
||||
|
||||
/// A box displaying a single statistic.
|
||||
private struct StatBox: View {
|
||||
let value: String
|
||||
let label: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.xSmall) {
|
||||
Text(value)
|
||||
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(color)
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: Design.BaseFontSize.small))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.secondary))
|
||||
}
|
||||
.frame(minWidth: Design.Size.statBoxMinWidth)
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.fill(Color.black.opacity(Design.Opacity.overlay))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A win stat display with count and percentage.
|
||||
private struct WinStatView: View {
|
||||
let title: String
|
||||
let count: Int
|
||||
let percentage: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.xSmall) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: Design.Size.winIndicatorSize, height: Design.Size.winIndicatorSize)
|
||||
|
||||
Text("\(count)")
|
||||
.font(.system(size: Design.BaseFontSize.title, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(percentage)
|
||||
.font(.system(size: Design.BaseFontSize.small))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.secondary))
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: Design.BaseFontSize.small))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
/// A pair stat display.
|
||||
private struct PairStatView: View {
|
||||
let title: String
|
||||
let count: Int
|
||||
let percentage: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.xSmall) {
|
||||
Text("\(count)")
|
||||
.font(.system(size: Design.BaseFontSize.title, weight: .bold))
|
||||
.foregroundStyle(color)
|
||||
|
||||
Text(percentage)
|
||||
.font(.system(size: Design.BaseFontSize.small))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.secondary))
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: Design.BaseFontSize.small))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.accent))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A horizontal bar showing win distribution.
|
||||
private struct WinDistributionBar: View {
|
||||
let playerWins: Int
|
||||
let tieCount: Int
|
||||
let bankerWins: Int
|
||||
|
||||
private var total: Int { playerWins + tieCount + bankerWins }
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
HStack(spacing: 0) {
|
||||
if playerWins > 0 {
|
||||
Rectangle()
|
||||
.fill(Color.blue)
|
||||
.frame(width: geometry.size.width * CGFloat(playerWins) / CGFloat(total))
|
||||
}
|
||||
|
||||
if tieCount > 0 {
|
||||
Rectangle()
|
||||
.fill(Color.green)
|
||||
.frame(width: geometry.size.width * CGFloat(tieCount) / CGFloat(total))
|
||||
}
|
||||
|
||||
if bankerWins > 0 {
|
||||
Rectangle()
|
||||
.fill(Color.red)
|
||||
.frame(width: geometry.size.width * CGFloat(bankerWins) / CGFloat(total))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Big Road scoreboard - a grid showing result patterns.
|
||||
/// Results are arranged in columns, with each column representing a streak of same results.
|
||||
private struct BigRoadView: View {
|
||||
let results: [RoundResult]
|
||||
|
||||
private let maxRows = 6
|
||||
private let cellSize: CGFloat = Design.Size.bigRoadCellSize
|
||||
|
||||
/// Convert results into columns for Big Road display.
|
||||
private var columns: [[RoundResult]] {
|
||||
var cols: [[RoundResult]] = []
|
||||
var currentCol: [RoundResult] = []
|
||||
var lastResult: GameResult?
|
||||
|
||||
for result in results {
|
||||
// Skip ties for column tracking (ties go in the current column)
|
||||
let currentResult = result.result
|
||||
|
||||
if currentResult == .tie {
|
||||
// Ties don't start new columns, they go with the current streak
|
||||
if !currentCol.isEmpty {
|
||||
currentCol.append(result)
|
||||
} else if !cols.isEmpty {
|
||||
cols[cols.count - 1].append(result)
|
||||
} else {
|
||||
currentCol.append(result)
|
||||
}
|
||||
} else if lastResult == nil || currentResult == lastResult {
|
||||
// Same as last or first result - continue column
|
||||
currentCol.append(result)
|
||||
lastResult = currentResult
|
||||
} else {
|
||||
// Different result - start new column
|
||||
if !currentCol.isEmpty {
|
||||
cols.append(currentCol)
|
||||
}
|
||||
currentCol = [result]
|
||||
lastResult = currentResult
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining column
|
||||
if !currentCol.isEmpty {
|
||||
cols.append(currentCol)
|
||||
}
|
||||
|
||||
return cols
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: Design.Spacing.xxSmall) {
|
||||
ForEach(Array(columns.enumerated()), id: \.offset) { _, column in
|
||||
VStack(spacing: Design.Spacing.xxSmall) {
|
||||
ForEach(Array(column.prefix(maxRows).enumerated()), id: \.offset) { _, result in
|
||||
BigRoadCell(result: result)
|
||||
}
|
||||
|
||||
// If column has more than maxRows, show overflow count
|
||||
if column.count > maxRows {
|
||||
Text("+\(column.count - maxRows)")
|
||||
.font(.system(size: Design.BaseFontSize.xxSmall))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.secondary))
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(Design.Spacing.small)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.fill(Color.black.opacity(Design.Opacity.overlay))
|
||||
)
|
||||
.scrollIndicators(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
/// A single cell in the Big Road display.
|
||||
private struct BigRoadCell: View {
|
||||
let result: RoundResult
|
||||
|
||||
private let cellSize: CGFloat = Design.Size.bigRoadCellSize
|
||||
|
||||
private var color: Color {
|
||||
switch result.result {
|
||||
case .playerWins: return .blue
|
||||
case .bankerWins: return .red
|
||||
case .tie: return .green
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Main circle
|
||||
Circle()
|
||||
.stroke(color, lineWidth: Design.LineWidth.medium)
|
||||
.frame(width: cellSize, height: cellSize)
|
||||
|
||||
// Pair indicator (small dot at bottom)
|
||||
if result.hasPair {
|
||||
Circle()
|
||||
.fill(Color.yellow)
|
||||
.frame(width: cellSize * 0.25, height: cellSize * 0.25)
|
||||
.offset(y: cellSize * 0.3)
|
||||
}
|
||||
|
||||
// Natural indicator (small dot at top)
|
||||
if result.isNatural {
|
||||
Circle()
|
||||
.fill(Color.white)
|
||||
.frame(width: cellSize * 0.25, height: cellSize * 0.25)
|
||||
.offset(y: -cellSize * 0.3)
|
||||
}
|
||||
|
||||
// Tie diagonal line if it's a tie
|
||||
if result.result == .tie {
|
||||
Rectangle()
|
||||
.fill(color)
|
||||
.frame(width: cellSize * 0.8, height: Design.LineWidth.medium)
|
||||
.rotationEffect(.degrees(-45))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Design Constants Extensions
|
||||
|
||||
extension Design.Size {
|
||||
static let bigRoadHeight: CGFloat = 200
|
||||
static let bigRoadCellSize: CGFloat = 24
|
||||
static let statBoxMinWidth: CGFloat = 80
|
||||
static let winIndicatorSize: CGFloat = 24
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
StatisticsSheetView(results: [
|
||||
RoundResult(result: .playerWins, playerValue: 8, bankerValue: 6, playerPair: true),
|
||||
RoundResult(result: .playerWins, playerValue: 7, bankerValue: 5),
|
||||
RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 7),
|
||||
RoundResult(result: .bankerWins, playerValue: 3, bankerValue: 8, bankerPair: true),
|
||||
RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 6),
|
||||
RoundResult(result: .tie, playerValue: 5, bankerValue: 5),
|
||||
RoundResult(result: .playerWins, playerValue: 9, bankerValue: 3),
|
||||
RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 8, playerPair: true, bankerPair: true)
|
||||
])
|
||||
}
|
||||
|
||||
@ -19,10 +19,12 @@
|
||||
// - ChipView, ChipEdgePattern
|
||||
// - ChipSelectorView
|
||||
// - ChipStackView, ChipOnTableView
|
||||
// - SheetContainerView, SheetSection
|
||||
|
||||
// MARK: - Theme
|
||||
// - CasinoTheme (protocol)
|
||||
// - DefaultCasinoTheme
|
||||
// - ChipColorSet
|
||||
// - CasinoDesign (constants)
|
||||
// - Color.Sheet (sheet colors)
|
||||
|
||||
|
||||
@ -332,8 +332,26 @@
|
||||
}
|
||||
},
|
||||
"MAX" : {
|
||||
"comment" : "A label indicating the maximum bet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "MAX"
|
||||
}
|
||||
},
|
||||
"es-MX" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "MÁX"
|
||||
}
|
||||
},
|
||||
"fr-CA" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "MAX"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"maximum bet" : {
|
||||
"localizations" : {
|
||||
|
||||
@ -132,5 +132,35 @@ public enum CasinoDesign {
|
||||
public static let comfortable: CGFloat = 0.6
|
||||
public static let relaxed: CGFloat = 0.7
|
||||
}
|
||||
|
||||
// MARK: - Max Chip Stack
|
||||
|
||||
public enum ChipStack {
|
||||
public static let maxChipsToShow: Int = 5
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CasinoKit Colors
|
||||
|
||||
/// Shared colors for casino game UI components.
|
||||
public extension Color {
|
||||
|
||||
/// Sheet and popup colors.
|
||||
enum Sheet {
|
||||
/// Dark background for sheets and popups.
|
||||
public static let background = Color(red: 0.08, green: 0.12, blue: 0.18)
|
||||
|
||||
/// Subtle fill for section cards.
|
||||
public static let sectionFill = Color.white.opacity(CasinoDesign.Opacity.subtle)
|
||||
|
||||
/// Accent color for buttons and highlights.
|
||||
public static let accent = Color.yellow
|
||||
|
||||
/// Secondary text color.
|
||||
public static let secondaryText = Color.white.opacity(CasinoDesign.Opacity.accent)
|
||||
|
||||
/// Cancel button color.
|
||||
public static let cancelText = Color.white.opacity(CasinoDesign.Opacity.strong)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,197 @@
|
||||
//
|
||||
// SheetContainerView.swift
|
||||
// CasinoKit
|
||||
//
|
||||
// A reusable container view for modal sheets with consistent styling.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// A reusable container for modal sheets providing consistent casino-themed styling.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```swift
|
||||
/// SheetContainerView(title: "Settings") {
|
||||
/// // Your content here
|
||||
/// } onDone: {
|
||||
/// dismiss()
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// With cancel button:
|
||||
/// ```swift
|
||||
/// SheetContainerView(
|
||||
/// title: "Settings",
|
||||
/// content: { ... },
|
||||
/// onCancel: { dismiss() },
|
||||
/// onDone: { save(); dismiss() }
|
||||
/// )
|
||||
/// ```
|
||||
public struct SheetContainerView<Content: View>: View {
|
||||
let title: String
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
/// Optional cancel action (shows Cancel button on left if provided)
|
||||
var onCancel: (() -> Void)?
|
||||
|
||||
/// Done action (always shown on right)
|
||||
let onDone: () -> Void
|
||||
|
||||
/// Done button text (defaults to "Done")
|
||||
var doneButtonText: String
|
||||
|
||||
/// Cancel button text (defaults to "Cancel")
|
||||
var cancelButtonText: String
|
||||
|
||||
/// Creates a sheet container with the specified configuration.
|
||||
/// - Parameters:
|
||||
/// - title: The navigation title for the sheet.
|
||||
/// - content: The content to display inside the sheet.
|
||||
/// - onCancel: Optional cancel action. If provided, shows a Cancel button.
|
||||
/// - onDone: The action to perform when Done is tapped.
|
||||
/// - doneButtonText: Custom text for the Done button.
|
||||
/// - cancelButtonText: Custom text for the Cancel button.
|
||||
public init(
|
||||
title: String,
|
||||
@ViewBuilder content: () -> Content,
|
||||
onCancel: (() -> Void)? = nil,
|
||||
onDone: @escaping () -> Void,
|
||||
doneButtonText: String = "Done",
|
||||
cancelButtonText: String = "Cancel"
|
||||
) {
|
||||
self.title = title
|
||||
self.content = content()
|
||||
self.onCancel = onCancel
|
||||
self.onDone = onDone
|
||||
self.doneButtonText = doneButtonText
|
||||
self.cancelButtonText = cancelButtonText
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
// Consistent background
|
||||
Color.Sheet.background
|
||||
.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: CasinoDesign.Spacing.xxLarge) {
|
||||
content
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarBackground(Color.Sheet.background, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
.toolbar {
|
||||
if let onCancel {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button(cancelButtonText) {
|
||||
onCancel()
|
||||
}
|
||||
.foregroundStyle(Color.Sheet.cancelText)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(doneButtonText) {
|
||||
onDone()
|
||||
}
|
||||
.bold()
|
||||
.foregroundStyle(Color.Sheet.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A styled section for use within sheets with icon + title header.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```swift
|
||||
/// SheetSection(title: "SETTINGS", icon: "gearshape") {
|
||||
/// Toggle("Enable Feature", isOn: $isEnabled)
|
||||
/// }
|
||||
/// ```
|
||||
public struct SheetSection<Content: View>: View {
|
||||
let title: String
|
||||
let icon: String
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
/// Creates a sheet section with an icon and title header.
|
||||
/// - Parameters:
|
||||
/// - title: The section title (displayed uppercase).
|
||||
/// - icon: The SF Symbol name for the icon.
|
||||
/// - content: The content to display inside the section card.
|
||||
public init(
|
||||
title: String,
|
||||
icon: String,
|
||||
@ViewBuilder content: () -> Content
|
||||
) {
|
||||
self.title = title
|
||||
self.icon = icon
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: CasinoDesign.Spacing.medium) {
|
||||
// Header with icon
|
||||
HStack(spacing: CasinoDesign.Spacing.small) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.small, weight: .semibold))
|
||||
.foregroundStyle(Color.Sheet.accent.opacity(CasinoDesign.Opacity.heavy))
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.small, weight: .bold, design: .rounded))
|
||||
.tracking(1)
|
||||
.foregroundStyle(Color.Sheet.secondaryText)
|
||||
}
|
||||
.padding(.horizontal, CasinoDesign.Spacing.xSmall)
|
||||
|
||||
// Content card
|
||||
VStack(spacing: 0) {
|
||||
content
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.large)
|
||||
.fill(Color.Sheet.sectionFill)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview("Sheet with Cancel") {
|
||||
SheetContainerView(
|
||||
title: "Example Sheet",
|
||||
content: {
|
||||
SheetSection(title: "SECTION ONE", icon: "star.fill") {
|
||||
Text("Content here")
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
SheetSection(title: "SECTION TWO", icon: "heart.fill") {
|
||||
Text("More content")
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
},
|
||||
onCancel: { },
|
||||
onDone: { }
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("Sheet without Cancel") {
|
||||
SheetContainerView(title: "Statistics") {
|
||||
SheetSection(title: "SUMMARY", icon: "chart.pie.fill") {
|
||||
Text("Stats here")
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
} onDone: { }
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user