582 lines
20 KiB
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)
|
|
}
|
|
|