diff --git a/Baccarat/Engine/GameState.swift b/Baccarat/Engine/GameState.swift index 4ecf734..62eeead 100644 --- a/Baccarat/Engine/GameState.swift +++ b/Baccarat/Engine/GameState.swift @@ -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 diff --git a/Baccarat/Models/GameResult.swift b/Baccarat/Models/GameResult.swift index 9cb34d1..1f873f5 100644 --- a/Baccarat/Models/GameResult.swift +++ b/Baccarat/Models/GameResult.swift @@ -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 } } diff --git a/Baccarat/Resources/Localizable.xcstrings b/Baccarat/Resources/Localizable.xcstrings index eb19ca3..6effb43 100644 --- a/Baccarat/Resources/Localizable.xcstrings +++ b/Baccarat/Resources/Localizable.xcstrings @@ -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" : { diff --git a/Baccarat/Views/GameTableView.swift b/Baccarat/Views/GameTableView.swift index b19e6f2..44c445e 100644 --- a/Baccarat/Views/GameTableView.swift +++ b/Baccarat/Views/GameTableView.swift @@ -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) diff --git a/Baccarat/Views/RoadMapView.swift b/Baccarat/Views/RoadMapView.swift index 26b0850..0c7b20a 100644 --- a/Baccarat/Views/RoadMapView.swift +++ b/Baccarat/Views/RoadMapView.swift @@ -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() } diff --git a/Baccarat/Views/RulesHelpView.swift b/Baccarat/Views/RulesHelpView.swift index 833bb74..6e69931 100644 --- a/Baccarat/Views/RulesHelpView.swift +++ b/Baccarat/Views/RulesHelpView.swift @@ -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)) } diff --git a/Baccarat/Views/SettingsView.swift b/Baccarat/Views/SettingsView.swift index b04230b..daf9a83 100644 --- a/Baccarat/Views/SettingsView.swift +++ b/Baccarat/Views/SettingsView.swift @@ -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: 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") + ) } } diff --git a/Baccarat/Views/StatisticsSheetView.swift b/Baccarat/Views/StatisticsSheetView.swift new file mode 100644 index 0000000..df5194a --- /dev/null +++ b/Baccarat/Views/StatisticsSheetView.swift @@ -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) + ]) +} + diff --git a/CasinoKit/Sources/CasinoKit/Exports.swift b/CasinoKit/Sources/CasinoKit/Exports.swift index 9853be5..ee4e0c7 100644 --- a/CasinoKit/Sources/CasinoKit/Exports.swift +++ b/CasinoKit/Sources/CasinoKit/Exports.swift @@ -19,10 +19,12 @@ // - ChipView, ChipEdgePattern // - ChipSelectorView // - ChipStackView, ChipOnTableView +// - SheetContainerView, SheetSection // MARK: - Theme // - CasinoTheme (protocol) // - DefaultCasinoTheme // - ChipColorSet // - CasinoDesign (constants) +// - Color.Sheet (sheet colors) diff --git a/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings b/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings index 9153d77..babcb0d 100644 --- a/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings +++ b/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings @@ -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" : { diff --git a/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift b/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift index ddb7df0..76d7323 100644 --- a/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift +++ b/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift @@ -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) + } } diff --git a/CasinoKit/Sources/CasinoKit/Views/Sheets/SheetContainerView.swift b/CasinoKit/Sources/CasinoKit/Views/Sheets/SheetContainerView.swift new file mode 100644 index 0000000..21cdfda --- /dev/null +++ b/CasinoKit/Sources/CasinoKit/Views/Sheets/SheetContainerView.swift @@ -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: 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: 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: { } +} +