442 lines
14 KiB
Swift
442 lines
14 KiB
Swift
//
|
|
// 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)
|
|
])
|
|
}
|
|
|