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(
|
roundHistory.append(RoundResult(
|
||||||
result: result,
|
result: result,
|
||||||
playerValue: playerHandValue,
|
playerValue: playerHandValue,
|
||||||
bankerValue: bankerHandValue
|
bankerValue: bankerHandValue,
|
||||||
|
playerPair: playerHadPair,
|
||||||
|
bankerPair: bankerHadPair
|
||||||
))
|
))
|
||||||
|
|
||||||
// Show result banner - stays until user taps New Round
|
// Show result banner - stays until user taps New Round
|
||||||
|
|||||||
@ -55,12 +55,32 @@ struct RoundResult: Identifiable {
|
|||||||
let result: GameResult
|
let result: GameResult
|
||||||
let playerValue: Int
|
let playerValue: Int
|
||||||
let bankerValue: Int
|
let bankerValue: Int
|
||||||
|
let playerPair: Bool
|
||||||
|
let bankerPair: Bool
|
||||||
let timestamp: Date
|
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.result = result
|
||||||
self.playerValue = playerValue
|
self.playerValue = playerValue
|
||||||
self.bankerValue = bankerValue
|
self.bankerValue = bankerValue
|
||||||
|
self.playerPair = playerPair
|
||||||
|
self.bankerPair = bankerPair
|
||||||
self.timestamp = .now
|
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" : {
|
"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" : {
|
"BANKER" : {
|
||||||
"comment" : "A label displayed above the banker's hand in the baccarat table view.",
|
"comment" : "A label displayed above the banker's hand in the baccarat table view.",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -671,6 +751,46 @@
|
|||||||
"comment" : "A section header for information about winning with a natural hand.",
|
"comment" : "A section header for information about winning with a natural hand.",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"New Round" : {
|
||||||
"comment" : "The label of a button that starts a new round of the game.",
|
"comment" : "The label of a button that starts a new round of the game.",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -714,6 +834,86 @@
|
|||||||
"comment" : "Summary text for a road map view when there is no history.",
|
"comment" : "Summary text for a road map view when there is no history.",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"Pair Bonus" : {
|
||||||
"comment" : "Title of the page explaining the pair bonus in Baccarat.",
|
"comment" : "Title of the page explaining the pair bonus in Baccarat.",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"PLAYER" : {
|
||||||
"comment" : "The label for the player's hand in the cards display area.",
|
"comment" : "The label for the player's hand in the cards display area.",
|
||||||
"localizations" : {
|
"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" : {
|
"Rounds Played" : {
|
||||||
"comment" : "A label displayed next to the number of rounds played in the game over screen.",
|
"comment" : "A label displayed next to the number of rounds played in the game over screen.",
|
||||||
"localizations" : {
|
"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" : {
|
"tableLimitsFormat" : {
|
||||||
"comment" : "Format string for table limits display. First argument is min bet, second is max bet.",
|
"comment" : "Format string for table limits display. First argument is min bet, second is max bet.",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@ -1140,6 +1460,46 @@
|
|||||||
},
|
},
|
||||||
"Third Card Rules" : {
|
"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" : {
|
"TIE" : {
|
||||||
"comment" : "The text displayed in the TIE betting zone.",
|
"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.",
|
"comment" : "A label displayed next to the total winnings in the result banner.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"View detailed game statistics" : {
|
||||||
|
"comment" : "A hint that appears when hovering over the \"Statistics\" button, explaining its function.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"WIN" : {
|
"WIN" : {
|
||||||
"comment" : "The text that appears as a badge when a player wins a hand in baccarat.",
|
"comment" : "The text that appears as a badge when a player wins a hand in baccarat.",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@ -15,6 +15,7 @@ struct GameTableView: View {
|
|||||||
@State private var selectedChip: ChipDenomination = .hundred
|
@State private var selectedChip: ChipDenomination = .hundred
|
||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
@State private var showRules = false
|
@State private var showRules = false
|
||||||
|
@State private var showStats = false
|
||||||
|
|
||||||
private var state: GameState {
|
private var state: GameState {
|
||||||
gameState ?? GameState(settings: settings)
|
gameState ?? GameState(settings: settings)
|
||||||
@ -46,7 +47,8 @@ struct GameTableView: View {
|
|||||||
showCardsRemaining: settings.showCardsRemaining,
|
showCardsRemaining: settings.showCardsRemaining,
|
||||||
onReset: { state.resetGame() },
|
onReset: { state.resetGame() },
|
||||||
onSettings: { showSettings = true },
|
onSettings: { showSettings = true },
|
||||||
onHelp: { showRules = true }
|
onHelp: { showRules = true },
|
||||||
|
onStats: { showStats = true }
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(minLength: Design.Spacing.xSmall)
|
Spacer(minLength: Design.Spacing.xSmall)
|
||||||
@ -149,6 +151,9 @@ struct GameTableView: View {
|
|||||||
.fullScreenCover(isPresented: $showRules) {
|
.fullScreenCover(isPresented: $showRules) {
|
||||||
RulesHelpView()
|
RulesHelpView()
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showStats) {
|
||||||
|
StatisticsSheetView(results: state.roundHistory)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -541,6 +546,7 @@ struct TopBarView: View {
|
|||||||
let onReset: () -> Void
|
let onReset: () -> Void
|
||||||
let onSettings: () -> Void
|
let onSettings: () -> Void
|
||||||
let onHelp: () -> Void
|
let onHelp: () -> Void
|
||||||
|
let onStats: () -> Void
|
||||||
|
|
||||||
// MARK: - Environment
|
// MARK: - Environment
|
||||||
|
|
||||||
@ -605,6 +611,18 @@ struct TopBarView: View {
|
|||||||
Spacer()
|
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
|
// Help/Rules button
|
||||||
Button("Help", systemImage: "info.circle.fill", action: onHelp)
|
Button("Help", systemImage: "info.circle.fill", action: onHelp)
|
||||||
.labelStyle(.iconOnly)
|
.labelStyle(.iconOnly)
|
||||||
|
|||||||
@ -42,7 +42,12 @@ struct RoadMapView: View {
|
|||||||
ScrollView(.horizontal) {
|
ScrollView(.horizontal) {
|
||||||
HStack(spacing: Design.Spacing.xSmall) {
|
HStack(spacing: Design.Spacing.xSmall) {
|
||||||
ForEach(results) { result in
|
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)
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
@ -65,6 +70,12 @@ struct RoadMapView: View {
|
|||||||
struct RoadDot: View {
|
struct RoadDot: View {
|
||||||
let result: GameResult
|
let result: GameResult
|
||||||
let dotSize: CGFloat
|
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)
|
// MARK: - Scaled Fonts (Dynamic Type)
|
||||||
|
|
||||||
@ -99,6 +110,26 @@ struct RoadDot: View {
|
|||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: labelFontSize, weight: .bold))
|
.font(.system(size: labelFontSize, weight: .bold))
|
||||||
.foregroundStyle(.white)
|
.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()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
RoadMapView(results: [
|
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: .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: .playerWins, playerValue: 9, bankerValue: 3),
|
||||||
RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 8)
|
RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 8, playerPair: true, bankerPair: true)
|
||||||
])
|
])
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,8 +42,8 @@ struct RulesHelpView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
// Background
|
// Background - same as other sheets
|
||||||
Color.black.opacity(Design.Opacity.almostFull)
|
Color.Settings.background
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: Design.Spacing.medium) {
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
@ -107,7 +107,7 @@ struct RulesHelpView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: contentCornerRadius)
|
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))
|
.clipShape(RoundedRectangle(cornerRadius: contentCornerRadius))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,158 +17,102 @@ struct SettingsView: View {
|
|||||||
@State private var hasChanges = false
|
@State private var hasChanges = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
SheetContainerView(
|
||||||
ZStack {
|
title: String(localized: "Settings"),
|
||||||
// Background
|
content: {
|
||||||
Color.Settings.background
|
// Table Limits Section (First!)
|
||||||
.ignoresSafeArea()
|
SheetSection(title: "TABLE LIMITS", icon: "banknote") {
|
||||||
|
TableLimitsPicker(selection: $settings.tableLimits)
|
||||||
ScrollView {
|
.onChange(of: settings.tableLimits) { _, _ in
|
||||||
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()
|
|
||||||
hasChanges = true
|
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) {
|
// Deck Settings Section
|
||||||
Button("Done") {
|
SheetSection(title: "DECK SETTINGS", icon: "rectangle.portrait.on.rectangle.portrait") {
|
||||||
settings.save()
|
DeckCountPicker(selection: $settings.deckCount)
|
||||||
if hasChanges {
|
.onChange(of: settings.deckCount) { _, _ in
|
||||||
onApplyChanges()
|
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)
|
// Starting Balance Section
|
||||||
.font(.system(size: Design.BaseFontSize.body, weight: .bold, design: .rounded))
|
SheetSection(title: "STARTING BALANCE", icon: "dollarsign.circle") {
|
||||||
.tracking(1)
|
BalancePicker(balance: $settings.startingBalance)
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.accent))
|
.onChange(of: settings.startingBalance) { _, _ in
|
||||||
}
|
hasChanges = true
|
||||||
.padding(.horizontal, Design.Spacing.xSmall)
|
}
|
||||||
|
}
|
||||||
// Content card
|
|
||||||
VStack(spacing: 0) {
|
// Display Settings Section
|
||||||
content
|
SheetSection(title: "DISPLAY", icon: "eye") {
|
||||||
}
|
SettingsToggle(
|
||||||
.padding()
|
title: "Show Cards Remaining",
|
||||||
.background(
|
subtitle: "Display deck counter at top",
|
||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
isOn: $settings.showCardsRemaining
|
||||||
.fill(Color.white.opacity(Design.Opacity.verySubtle))
|
)
|
||||||
)
|
|
||||||
}
|
Divider()
|
||||||
.padding(.horizontal)
|
.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
|
// - ChipView, ChipEdgePattern
|
||||||
// - ChipSelectorView
|
// - ChipSelectorView
|
||||||
// - ChipStackView, ChipOnTableView
|
// - ChipStackView, ChipOnTableView
|
||||||
|
// - SheetContainerView, SheetSection
|
||||||
|
|
||||||
// MARK: - Theme
|
// MARK: - Theme
|
||||||
// - CasinoTheme (protocol)
|
// - CasinoTheme (protocol)
|
||||||
// - DefaultCasinoTheme
|
// - DefaultCasinoTheme
|
||||||
// - ChipColorSet
|
// - ChipColorSet
|
||||||
// - CasinoDesign (constants)
|
// - CasinoDesign (constants)
|
||||||
|
// - Color.Sheet (sheet colors)
|
||||||
|
|
||||||
|
|||||||
@ -332,8 +332,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"MAX" : {
|
"MAX" : {
|
||||||
"comment" : "A label indicating the maximum bet.",
|
"localizations" : {
|
||||||
"isCommentAutoGenerated" : true
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "MAX"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es-MX" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "MÁX"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-CA" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "MAX"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"maximum bet" : {
|
"maximum bet" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@ -132,5 +132,35 @@ public enum CasinoDesign {
|
|||||||
public static let comfortable: CGFloat = 0.6
|
public static let comfortable: CGFloat = 0.6
|
||||||
public static let relaxed: CGFloat = 0.7
|
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