← poker-tutor
import SwiftUI
import PokerCore
import PokerCoach
import PokerSession
/// The leak scorecard: what is costing the player money, worst first.
///
/// Everything here is read off the saved hands through `LeakScorecard`, so it follows the scope bar
/// at the top of the screen — this sitting, one earlier sitting, or everything ever played — and it
/// is all still there after the app has been closed. The view does no counting and no ranking of its
/// own; the order, the small-sample rules and every sentence of the honest ones come out of
/// `LeakScorecard`, which is testable, while this file only draws them.
struct ScorecardBoard: View {
let scorecard: LeakScorecard
/// What to do when a row is picked up as something to practise. Passed in rather than reached
/// for, so this file still knows nothing about how a drill runs.
var drill: ((Leak) -> Void)? = nil
/// What a free player may read of this. `.open` for a subscriber and everywhere the card is
/// drawn with nobody to charge.
var free: FreeView = .open
/// Where a blurred row sends a tap.
var showPrice: () -> Void = {}
@State var showing: GlossaryTerm?
var body: some View {
VStack(alignment: .leading, spacing: 14) {
GlossaryText(scorecard.summary, colour: Theme.text, showing: $showing)
.fixedSize(horizontal: false, vertical: true)
if let note = scorecard.sampleNote {
Text(note).caption().foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
}
DefinitionSlot(showing: $showing)
if scorecard.costing.isEmpty {
Text(Copy.scorecardEmpty)
.body().foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
} else {
if free.isRestricted, free.hiddenCount > 0, free.basis == .named {
HiddenCountLine(count: free.hiddenCount, open: showPrice)
}
VStack(spacing: 18) {
ForEach(Array(scorecard.costing.enumerated()), id: \.element.id) { place, entry in
ScorecardRow(entry: entry, place: place + 1, now: scorecard.asOf, drill: drill,
freeLabel: free.isRestricted && entry.id == free.visibleLeakID
? Copy.freeRowNamed : nil)
.hiddenUntilSubscribed(free.isBlurred(leakID: entry.id), open: showPrice)
}
}
}
}
}
}
/// "N more habits are in your game." — the line over the blurred rows.
struct HiddenCountLine: View {
let count: Int
let open: () -> Void
var body: some View {
Button(action: open) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Symbol("lock.fill", size: Theme.size(10, capped: true)).foregroundStyle(Theme.gold)
Text(Copy.hiddenHabits(count)).caption(weight: .medium).foregroundStyle(Theme.gold)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
/// One leak, with everything the player needs to decide whether to do something about it: how often
/// it happens, how often it should, what it has cost, where it happens, and what to practise.
struct ScorecardRow: View {
let entry: LeakScorecard.Entry
/// Its place in the ranking, or nil for the ones too thin to be ranked at all.
var place: Int? = nil
/// The moment the drill dates on this row are read against.
var now: Date = Date()
var drill: ((Leak) -> Void)? = nil
/// The free tier's line over the one row a free player is shown, if this is that row.
var freeLabel: String? = nil
@State var showing: GlossaryTerm?
private var tint: Color { place == nil ? Theme.gold : Theme.warn }
var body: some View {
VStack(alignment: .leading, spacing: 7) {
if let freeLabel {
Text(freeLabel).caption(weight: .medium).foregroundStyle(Theme.accent)
.fixedSize(horizontal: false, vertical: true)
}
HStack(alignment: .firstTextBaseline, spacing: 8) {
if let place {
Text("\(place)").numericSmall().foregroundStyle(Theme.dim)
.frame(width: Theme.size(16), alignment: .trailing)
}
Text(entry.leak.name).heading().foregroundStyle(tint)
Spacer(minLength: 8)
cost
}
RateBar(rate: entry.rate, acceptable: entry.leak.acceptableRate)
Text(entry.frequencyLine).caption().foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
if let priced = entry.pricedNote {
Text(priced).caption().foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
}
GlossaryText(entry.leak.explanation, showing: $showing)
if let seatLine = entry.seatLine {
GlossaryText(seatLine, size: TextRole.caption.base, colour: Theme.text.opacity(0.8), showing: $showing)
}
if let note = entry.sampleNote {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Symbol("exclamationmark.circle", size: Theme.size(10, capped: true)).foregroundStyle(Theme.gold)
Text(note).caption().foregroundStyle(Theme.gold.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
}
}
if let drill {
DrillStrip(entry: entry, now: now, start: drill)
} else {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Symbol("target", size: Theme.size(10, capped: true))
.foregroundStyle(Theme.accent)
GlossaryText(Copy.drillHintLine(entry.leak.drillHint), size: TextRole.caption.base,
colour: Theme.accent.opacity(0.85), showing: $showing)
}
}
DefinitionSlot(showing: $showing)
}
}
/// The money, or an honest blank where there is no honest number to print. A nought here would
/// read as "this one is free", which is a claim nobody has earned the right to make.
@ViewBuilder private var cost: some View {
if let costInBB = entry.costInBB {
VStack(alignment: .trailing, spacing: 1) {
Text(String(format: "%.1f bb", costInBB)).numeric().foregroundStyle(Theme.warn)
Text("so far").caption(weight: .medium).foregroundStyle(Theme.dim)
}
} else {
Text(Copy.costNotKnown).caption().foregroundStyle(Theme.dim)
}
}
}
/// The whole catalogue, measured or not — the part of the screen that shows what is going right.
struct ScorecardIndex: View {
let scorecard: LeakScorecard
var free: FreeView = .open
var showPrice: () -> Void = {}
@Environment(\.metrics) var metrics
var body: some View {
VStack(spacing: 10) {
ForEach(scorecard.allEntries) { entry in
// A row that has never been measured gives nothing away and stays readable; a
// measured one is a rate about the player, and is blurred like the rest.
let hidden = entry.chances > 0 && free.isBlurred(leakID: entry.id)
HStack(spacing: 12) {
Text(entry.leak.name).body()
.foregroundStyle(entry.chances > 0 ? Theme.text : Theme.dim.opacity(0.6))
.frame(width: Theme.size(metrics.compact ? 150 : 250), alignment: .leading)
// No bar at all where the situation has never come up. A nought-length fill is
// drawn at a three-point minimum, so an unmeasured habit came out looking
// exactly like one measured and clean.
RateBar(rate: entry.rate, acceptable: entry.leak.acceptableRate, height: 6,
measured: entry.chances > 0)
Text(entry.chances > 0 ? "\(entry.times)/\(entry.chances)" : "—")
.numericSmall().foregroundStyle(Theme.dim)
.frame(width: Theme.size(56), alignment: .trailing)
}
.hiddenUntilSubscribed(hidden, open: showPrice)
}
HStack {
Text(Copy.barMarkNote)
.caption().foregroundStyle(Theme.dim.opacity(0.8))
.fixedSize(horizontal: false, vertical: true)
Spacer()
}
}
}
}