CasinoGames/CasinoKit/Sources/CasinoKit/Views/Session/StatisticsComponents.swift

582 lines
20 KiB
Swift

//
// StatisticsComponents.swift
// CasinoKit
//
// Reusable UI components for statistics display.
// Used in StatisticsSheetView across all casino games.
//
import SwiftUI
// MARK: - Statistics Tab
/// Tab options for statistics views.
public enum StatisticsTab: CaseIterable, Sendable {
case current
case global
case history
public var title: String {
switch self {
case .current: return String(localized: "Current", bundle: .module)
case .global: return String(localized: "Global", bundle: .module)
case .history: return String(localized: "History", bundle: .module)
}
}
public var icon: String {
switch self {
case .current: return "play.circle.fill"
case .global: return "globe"
case .history: return "clock.arrow.circlepath"
}
}
}
// MARK: - Statistics Tab Selector
/// A tab selector for switching between Current, Global, and History tabs.
public struct StatisticsTabSelector: View {
@Binding public var selectedTab: StatisticsTab
public init(selectedTab: Binding<StatisticsTab>) {
self._selectedTab = selectedTab
}
public var body: some View {
HStack(spacing: 0) {
ForEach(StatisticsTab.allCases, id: \.self) { tab in
Button {
withAnimation(.spring(duration: CasinoDesign.Animation.springDuration)) {
selectedTab = tab
}
} label: {
VStack(spacing: CasinoDesign.Spacing.xSmall) {
Image(systemName: tab.icon)
.font(.system(size: CasinoDesign.BaseFontSize.large))
Text(tab.title)
.font(.system(size: CasinoDesign.BaseFontSize.small, weight: .medium))
}
.foregroundStyle(selectedTab == tab ? Color.Sheet.accent : .white.opacity(CasinoDesign.Opacity.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, CasinoDesign.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium)
.fill(selectedTab == tab ? Color.Sheet.accent.opacity(CasinoDesign.Opacity.hint) : .clear)
)
}
}
}
.padding(.horizontal)
}
}
// MARK: - Stat Column
/// A vertical column displaying a value and label, used in summary sections.
public struct StatColumn: View {
public let value: String
public let label: String
public var valueColor: Color
public init(value: String, label: String, valueColor: Color = .white) {
self.value = value
self.label = label
self.valueColor = valueColor
}
public var body: some View {
VStack(spacing: CasinoDesign.Spacing.xSmall) {
Text(value)
.font(.system(size: CasinoDesign.BaseFontSize.large, weight: .bold, design: .rounded))
.foregroundStyle(valueColor)
Text(label)
.font(.system(size: CasinoDesign.BaseFontSize.small))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Outcome Circle
/// A circular indicator showing win/loss/push counts with a colored ring.
public struct OutcomeCircle: View {
public let label: String
public let count: Int
public let color: Color
// Local size constants
private let circleSize: CGFloat = 48
private let innerSize: CGFloat = 24
public init(label: String, count: Int, color: Color) {
self.label = label
self.count = count
self.color = color
}
public var body: some View {
VStack(spacing: CasinoDesign.Spacing.small) {
Text(label)
.font(.system(size: CasinoDesign.BaseFontSize.small))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
Text("\(count)")
.font(.system(size: CasinoDesign.BaseFontSize.large, weight: .bold))
.foregroundStyle(.white)
ZStack {
Circle()
.fill(Color.black.opacity(CasinoDesign.Opacity.light))
.frame(width: circleSize, height: circleSize)
Circle()
.stroke(color, lineWidth: CasinoDesign.LineWidth.thick)
.frame(width: circleSize, height: circleSize)
Circle()
.fill(color.opacity(CasinoDesign.Opacity.medium))
.frame(width: innerSize, height: innerSize)
}
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Stat Row
/// A horizontal row with icon, label, and value.
public struct StatRow: View {
public let icon: String
public let label: String
public let value: String
public var valueColor: Color
// Local size constants
private let iconWidth: CGFloat = 32
public init(icon: String, label: String, value: String, valueColor: Color = .white) {
self.icon = icon
self.label = label
self.value = value
self.valueColor = valueColor
}
public var body: some View {
HStack {
Image(systemName: icon)
.font(.system(size: CasinoDesign.BaseFontSize.large))
.foregroundStyle(Color.Sheet.accent)
.frame(width: iconWidth)
Text(label)
.font(.system(size: CasinoDesign.BaseFontSize.body))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.strong))
Spacer()
Text(value)
.font(.system(size: CasinoDesign.BaseFontSize.body, weight: .semibold, design: .rounded))
.foregroundStyle(valueColor)
}
}
}
// MARK: - Chip Stat Row
/// A row displaying a stat with a colored circular icon, commonly used for chip/money stats.
public struct ChipStatRow: View {
public let icon: String
public let iconColor: Color
public let label: String
public let value: String
// Local size constants
private let chipIconSize: CGFloat = 28
public init(icon: String, iconColor: Color, label: String, value: String) {
self.icon = icon
self.iconColor = iconColor
self.label = label
self.value = value
}
public var body: some View {
HStack {
ZStack {
Circle()
.fill(iconColor)
.frame(width: chipIconSize, height: chipIconSize)
Image(systemName: icon)
.font(.system(size: CasinoDesign.BaseFontSize.small, weight: .bold))
.foregroundStyle(.white)
}
Text(label)
.font(.system(size: CasinoDesign.BaseFontSize.body))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.strong))
Spacer()
Text(value)
.font(.system(size: CasinoDesign.BaseFontSize.body, weight: .semibold, design: .rounded))
.foregroundStyle(Color.Sheet.accent)
}
}
}
// MARK: - No Active Session View
/// Placeholder view shown when there's no active session.
public struct NoActiveSessionView: View {
public init() {}
public var body: some View {
VStack(spacing: CasinoDesign.Spacing.large) {
Image(systemName: "play.slash")
.font(.system(size: CasinoDesign.BaseFontSize.xxLarge * 2))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.light))
Text(String(localized: "No Active Session", bundle: .module))
.font(.system(size: CasinoDesign.BaseFontSize.large, weight: .semibold))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
Text(String(localized: "Start playing to begin tracking your session.", bundle: .module))
.font(.system(size: CasinoDesign.BaseFontSize.body))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.light))
.multilineTextAlignment(.center)
}
.padding(CasinoDesign.Spacing.xxLarge)
}
}
// MARK: - Empty History View
/// Placeholder view shown when session history is empty.
public struct EmptyHistoryView: View {
public init() {}
public var body: some View {
VStack(spacing: CasinoDesign.Spacing.large) {
Image(systemName: "clock.arrow.circlepath")
.font(.system(size: CasinoDesign.BaseFontSize.xxLarge * 2))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.light))
Text(String(localized: "No Session History", bundle: .module))
.font(.system(size: CasinoDesign.BaseFontSize.large, weight: .semibold))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
Text(String(localized: "Completed sessions will appear here.", bundle: .module))
.font(.system(size: CasinoDesign.BaseFontSize.body))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.light))
.multilineTextAlignment(.center)
}
.padding(CasinoDesign.Spacing.xxLarge)
}
}
// MARK: - Session Performance Section
/// A reusable section showing session win/loss performance stats.
public struct SessionPerformanceSection: View {
public let winningSessions: Int
public let losingSessions: Int
public let bestSession: Int
public let worstSession: Int
public init(
winningSessions: Int,
losingSessions: Int,
bestSession: Int,
worstSession: Int
) {
self.winningSessions = winningSessions
self.losingSessions = losingSessions
self.bestSession = bestSession
self.worstSession = worstSession
}
public var body: some View {
VStack(spacing: CasinoDesign.Spacing.medium) {
HStack {
Text(String(localized: "Winning sessions", bundle: .module))
Spacer()
Text("\(winningSessions)")
.foregroundStyle(.green)
.bold()
}
HStack {
Text(String(localized: "Losing sessions", bundle: .module))
Spacer()
Text("\(losingSessions)")
.foregroundStyle(.red)
.bold()
}
Divider().background(Color.white.opacity(CasinoDesign.Opacity.hint))
HStack {
Text(String(localized: "Best session", bundle: .module))
Spacer()
Text(SessionFormatter.formatMoney(bestSession))
.foregroundStyle(.green)
.bold()
}
HStack {
Text(String(localized: "Worst session", bundle: .module))
Spacer()
Text(SessionFormatter.formatMoney(worstSession))
.foregroundStyle(.red)
.bold()
}
}
.font(.system(size: CasinoDesign.BaseFontSize.body))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.strong))
}
}
// MARK: - Chips Stats Section
/// A reusable section showing chip/betting statistics.
public struct ChipsStatsSection: View {
public let totalWinnings: Int
public let biggestWin: Int
public let biggestLoss: Int
public let totalBetAmount: Int
public let averageBet: Int?
public let biggestBet: Int
public init(
totalWinnings: Int,
biggestWin: Int,
biggestLoss: Int,
totalBetAmount: Int,
averageBet: Int?,
biggestBet: Int
) {
self.totalWinnings = totalWinnings
self.biggestWin = biggestWin
self.biggestLoss = biggestLoss
self.totalBetAmount = totalBetAmount
self.averageBet = averageBet
self.biggestBet = biggestBet
}
public var body: some View {
VStack(spacing: CasinoDesign.Spacing.medium) {
ChipStatRow(
icon: "chart.line.uptrend.xyaxis",
iconColor: totalWinnings >= 0 ? .green : .red,
label: String(localized: "Total gain", bundle: .module),
value: SessionFormatter.formatMoney(totalWinnings)
)
ChipStatRow(
icon: "arrow.up.circle.fill",
iconColor: .green,
label: String(localized: "Best gain", bundle: .module),
value: SessionFormatter.formatMoney(biggestWin)
)
ChipStatRow(
icon: "arrow.down.circle.fill",
iconColor: .red,
label: String(localized: "Worst loss", bundle: .module),
value: SessionFormatter.formatMoney(biggestLoss)
)
Divider().background(Color.white.opacity(CasinoDesign.Opacity.hint))
ChipStatRow(
icon: "plusminus.circle.fill",
iconColor: .blue,
label: String(localized: "Total bet", bundle: .module),
value: "$\(totalBetAmount)"
)
if let avg = averageBet {
ChipStatRow(
icon: "equal.circle.fill",
iconColor: .purple,
label: String(localized: "Average bet", bundle: .module),
value: "$\(avg)"
)
}
ChipStatRow(
icon: "star.circle.fill",
iconColor: .orange,
label: String(localized: "Biggest bet", bundle: .module),
value: "$\(biggestBet)"
)
}
}
}
// MARK: - Balance Section
/// A reusable section showing starting/ending balance for a session.
public struct BalanceSection: View {
public let startingBalance: Int
public let endingBalance: Int
public let netResult: Int
public init(startingBalance: Int, endingBalance: Int, netResult: Int) {
self.startingBalance = startingBalance
self.endingBalance = endingBalance
self.netResult = netResult
}
public var body: some View {
VStack(spacing: CasinoDesign.Spacing.medium) {
HStack {
Text(String(localized: "Starting balance", bundle: .module))
Spacer()
Text("$\(startingBalance)")
.bold()
}
HStack {
Text(String(localized: "Ending balance", bundle: .module))
Spacer()
Text("$\(endingBalance)")
.foregroundStyle(netResult >= 0 ? .green : .red)
.bold()
}
}
.font(.system(size: CasinoDesign.BaseFontSize.body))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.strong))
}
}
// MARK: - Session Header
/// Header showing session date, end reason, net result, and win rate.
public struct SessionDetailHeader: View {
public let startTime: Date
public let endReason: SessionEndReason?
public let netResult: Int
public let winRate: Double
public init(
startTime: Date,
endReason: SessionEndReason?,
netResult: Int,
winRate: Double
) {
self.startTime = startTime
self.endReason = endReason
self.netResult = netResult
self.winRate = winRate
}
public var body: some View {
VStack(spacing: CasinoDesign.Spacing.small) {
HStack {
VStack(alignment: .leading, spacing: CasinoDesign.Spacing.xxSmall) {
Text(SessionFormatter.formatSessionDate(startTime))
.font(.system(size: CasinoDesign.BaseFontSize.body, weight: .medium))
.foregroundStyle(.white)
if let endReason = endReason {
HStack(spacing: CasinoDesign.Spacing.xSmall) {
Image(systemName: endReason == .brokeOut ? "xmark.circle.fill" : "checkmark.circle.fill")
.foregroundStyle(endReason == .brokeOut ? .red : .green)
Text(endReason == .brokeOut
? String(localized: "Ran out of chips", bundle: .module)
: String(localized: "Ended manually", bundle: .module))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
}
.font(.system(size: CasinoDesign.BaseFontSize.small))
}
}
Spacer()
VStack(alignment: .trailing, spacing: CasinoDesign.Spacing.xxSmall) {
Text(SessionFormatter.formatMoney(netResult))
.font(.system(size: CasinoDesign.BaseFontSize.xLarge, weight: .bold, design: .rounded))
.foregroundStyle(netResult >= 0 ? .green : .red)
Text(SessionFormatter.formatPercent(winRate) + " " + String(localized: "win rate", bundle: .module))
.font(.system(size: CasinoDesign.BaseFontSize.small))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
}
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium)
.fill(Color.Sheet.sectionFill)
)
.padding(.horizontal)
}
}
// MARK: - Delete Session Button
/// A styled button for deleting a session.
public struct DeleteSessionButton: View {
public let action: () -> Void
public init(action: @escaping () -> Void) {
self.action = action
}
public var body: some View {
Button(role: .destructive, action: action) {
HStack {
Image(systemName: "trash")
Text(String(localized: "Delete Session", bundle: .module))
}
.font(.system(size: CasinoDesign.BaseFontSize.body, weight: .medium))
.frame(maxWidth: .infinity)
.padding(CasinoDesign.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium)
.fill(Color.red.opacity(CasinoDesign.Opacity.hint))
)
.foregroundStyle(.red)
}
.padding(.horizontal)
.padding(.top, CasinoDesign.Spacing.large)
}
}
// MARK: - Previews
#Preview("Statistics Components") {
ScrollView {
VStack(spacing: 24) {
StatisticsTabSelector(selectedTab: .constant(.current))
HStack(spacing: CasinoDesign.Spacing.large) {
StatColumn(value: "15", label: "Sessions")
StatColumn(value: "234", label: "Hands")
StatColumn(value: "52.3%", label: "Win Rate", valueColor: .green)
}
.padding()
HStack(spacing: CasinoDesign.Spacing.medium) {
OutcomeCircle(label: "Won", count: 45, color: .white)
OutcomeCircle(label: "Lost", count: 38, color: .red)
OutcomeCircle(label: "Push", count: 12, color: .gray)
}
.padding()
VStack(spacing: CasinoDesign.Spacing.medium) {
StatRow(icon: "clock", label: "Total game time", value: "02h 15min")
ChipStatRow(icon: "chart.line.uptrend.xyaxis", iconColor: .green, label: "Total gain", value: "$1,250")
}
.padding()
NoActiveSessionView()
EmptyHistoryView()
}
}
.background(Color.Sheet.background)
}