← poker-tutor
import SwiftUI
import PokerCore
import PokerCoach
import PokerSession
/// What an assessment is, before anybody agrees to sit through one.
///
/// The whole of it is said up front, including the awkward part: the table is arranged rather than
/// dealt, and there is a reason that does not spoil the measurement. Somebody who reads that at the
/// end and not at the beginning is entitled to feel they were not told.
struct AssessmentBrief: View {
@Environment(AppState.self) var app
@Environment(\.metrics) var metrics
@State var showing: GlossaryTerm?
private var resuming: SessionSummary? { app.resumableAssessment }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
Text(Copy.assessmentFinishedTitle).title().foregroundStyle(Theme.text)
Text(Copy.assessmentLength(AssessmentRun.standardLength))
.body().foregroundStyle(Theme.dim)
}
.padding(metrics.compact ? 16 : 22)
Divider().overlay(Color.white.opacity(0.07))
ScrollView {
VStack(alignment: .leading, spacing: 14) {
GlossaryText(Copy.assessmentIntro(AssessmentRun.standardLength),
size: 13, colour: Theme.text, showing: $showing)
Point(title: Copy.everyHandIsChosen, text: Copy.everyHandIsChosenBlurb,
showing: $showing)
Point(title: Copy.cardsNotChosen, text: Copy.cardsNotChosenBlurb,
showing: $showing)
Point(title: Copy.stopWhenever, text: Copy.stopWheneverBlurb,
showing: $showing)
Point(title: Copy.noPretending,
text: Copy.noPretendingBlurb(AssessmentRun.standardLength),
showing: $showing)
DefinitionSlot(showing: $showing)
}
.padding(metrics.compact ? 18 : 22)
}
Divider().overlay(Color.white.opacity(0.07))
HStack(spacing: 10) {
if let resuming {
PillButton(title: "Carry on",
subtitle: "\(resuming.hands) of \(AssessmentRun.standardLength) played",
wide: metrics.compact) {
app.resumeAssessment()
}
PillButton(title: "Start again", quiet: true, wide: metrics.compact) {
app.beginAssessment()
}
} else {
PillButton(title: "Start", wide: metrics.compact) { app.beginAssessment() }
PillButton(title: "Not now", quiet: true, wide: metrics.compact) {
app.dismissSheet()
}
}
}
.padding(18)
}
.frame(width: metrics.compact ? nil : Theme.size(620))
.frame(maxWidth: metrics.compact ? .infinity : nil,
maxHeight: metrics.compact ? .infinity : nil, alignment: .top)
.background(Theme.panel)
}
}
/// One numbered thing in the brief and in the report.
struct Point: View {
let title: String
let text: String
@Binding var showing: GlossaryTerm?
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title).heading().foregroundStyle(Theme.accent)
GlossaryText(text, size: TextRole.body.base, colour: Theme.dim, showing: $showing)
}
}
}
/// The band above the felt while an assessment runs: how far through it is, and what this
/// particular hand is being used to find out.
struct AssessmentBanner: View {
/// Only so an open definition can be closed when the hand moves on. Ken, 2026-08-22:
/// "a text box turned up toward the top of the screen giving my advice about position
/// and it is did not go away in the next hand." A definition is about the hand being
/// played; it has no business outliving it.
let handNumber: Int
let run: AssessmentRun
@Environment(\.metrics) var metrics
@State var showing: GlossaryTerm?
var body: some View {
VStack(alignment: .leading, spacing: 7) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(run.progressLine).caption(weight: .medium).foregroundStyle(Theme.accent)
Spacer(minLength: 8)
Text("\(run.handsLeft) to go").caption().foregroundStyle(Theme.dim)
}
ProgressStrip(fraction: run.fraction)
// The seat leads with its number, as everywhere else — "seat 2 of 9 (the big blind)"
// says the thing the traditional name was standing in for.
GlossaryText(run.setup.introduction(atTableOf: TableSession.seatsAtTable),
size: 11, colour: Theme.text.opacity(0.85), showing: $showing)
// What the hand is looking at is deliberately not here. These hands count towards the
// leak scorecard, and naming the habit being measured while it is being measured
// changes the thing measured — it is the same objection that keeps drill hands out of
// the scorecard altogether. The whole list is in the report instead, where saying it
// costs nothing. The seat and the stacks stay, because the player can see both of them
// on the felt anyway and hiding what is in plain sight buys the measurement nothing.
DefinitionSlot(showing: $showing)
}
.padding(.horizontal, metrics.compact ? 14 : 22)
.padding(.vertical, 9)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Theme.panelRaised)
.onChange(of: handNumber) { _, _ in showing = nil }
}
}
struct ProgressStrip: View {
let fraction: Double
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.08))
Capsule().fill(Theme.accent)
.frame(width: max(2, geo.size.width * min(1, max(0, fraction))))
}
}
.frame(height: 5)
}
}
/// What the sitting concluded.
///
/// Read from the top it is: what happened, what it is worth, what I am sure of, what I only
/// suspect, what I never saw, and what I arranged. The order is deliberate — the caveats sit with
/// the findings rather than in a footnote nobody reaches.
struct AssessmentReportView: View {
@Environment(AppState.self) var app
@Environment(\.metrics) var metrics
@State var showing: GlossaryTerm?
let report: AssessmentReport
var body: some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
Text(!report.finished ? Copy.assessmentStoppedTitle
: (app.assessmentJustFinished
? Copy.assessmentJustFinishedTitle(hands: report.hands)
: Copy.assessmentFinishedTitle))
.title().foregroundStyle(Theme.text)
GlossaryText(report.headline, size: TextRole.body.base, colour: Theme.dim, showing: $showing)
}
.padding(metrics.compact ? 16 : 22)
Divider().overlay(Color.white.opacity(0.07))
ScrollView {
VStack(alignment: .leading, spacing: 20) {
rating
if !report.named.isEmpty { findings(report.named, title: Copy.alreadyClear) }
if !report.watching.isEmpty {
findings(report.watching, title: Copy.whereToLookFirst,
note: Copy.whereToLookFirstNote)
}
if report.named.isEmpty, report.watching.isEmpty {
Panel(title: Copy.nothingToName) {
GlossaryText(report.quietNote, size: TextRole.body.base, colour: Theme.dim, showing: $showing)
}
}
coverage
steering
DefinitionSlot(showing: $showing)
}
.padding(metrics.compact ? 18 : 22)
}
Divider().overlay(Color.white.opacity(0.07))
HStack(spacing: 10) {
PillButton(title: "See my game", wide: metrics.compact) {
app.dismissSheet()
app.scope = .allTime
app.screen = .leaks
}
PillButton(title: "Close", quiet: true, wide: metrics.compact) {
app.dismissSheet()
}
}
.padding(18)
}
.frame(width: metrics.compact ? nil : Theme.size(660))
.frame(maxWidth: metrics.compact ? .infinity : nil,
maxHeight: metrics.compact ? .infinity : nil, alignment: .top)
.background(Theme.panel)
}
private var rating: some View {
Panel(title: Copy.startingRating) {
HStack(alignment: .lastTextBaseline, spacing: 10) {
Text("\(report.rating.value)").numeric(42, weight: .bold, capped: true)
.foregroundStyle(Theme.accent)
VStack(alignment: .leading, spacing: 2) {
Text(report.rating.label).heading().foregroundStyle(Theme.text)
Text(Copy.ratingRangeLine(low: report.ratingRange.lowerBound,
high: report.ratingRange.upperBound,
hands: report.hands))
.caption().foregroundStyle(Theme.dim)
}
}
GlossaryText(report.precisionNote, size: TextRole.body.base, colour: Theme.dim, showing: $showing)
}
}
private func findings(_ list: [AssessmentReport.Named], title: String,
note: String? = nil) -> some View {
// The free tier: the one habit in full, the others blurred with the count over them. The
// choice is `AppState.freeView`'s, read over everything played — the same habit the
// scorecard shows, so the two screens cannot name different ones.
let free = app.freeView
let hiddenHere = list.filter { free.isBlurred(leakID: $0.id) }.count
return Panel(title: title, subtitle: note) {
VStack(alignment: .leading, spacing: 18) {
if free.isRestricted, hiddenHere > 0 {
HiddenCountLine(count: hiddenHere) { app.showPaywall() }
}
ForEach(list) { finding in
VStack(alignment: .leading, spacing: 6) {
Text(finding.leak.name).heading()
.foregroundStyle(finding.certain ? Theme.warn : Theme.gold)
RateBar(rate: finding.rate, acceptable: finding.leak.acceptableRate)
Text(finding.line).caption().foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
GlossaryText(finding.leak.explanation, showing: $showing)
}
.hiddenUntilSubscribed(free.isBlurred(leakID: finding.id)) { app.showPaywall() }
}
}
}
}
private var coverage: some View {
Panel(title: Copy.whatIGotToLookAt,
subtitle: Copy.decisionsAcrossHands(decisions: report.decisions,
hands: report.hands)) {
VStack(spacing: 9) {
ForEach(report.areas) { area in
HStack(spacing: 12) {
Text(area.name).body()
.foregroundStyle(area.readable ? Theme.text : Theme.dim.opacity(0.6))
.frame(width: Theme.size(metrics.compact ? 140 : 210), alignment: .leading)
ProgressStrip(fraction: area.certainty)
Text(area.chances > 0 ? "\(area.mistakes)/\(area.chances)" : "—")
.numericSmall().foregroundStyle(Theme.dim)
.frame(width: Theme.size(52), alignment: .trailing)
}
}
}
if let thin = report.thinNote {
GlossaryText(thin, size: TextRole.caption.base, colour: Theme.gold.opacity(0.9), showing: $showing)
}
}
}
private var steering: some View {
Panel(title: Copy.whatIArranged) {
GlossaryText(report.steeringNote, size: TextRole.body.base, colour: Theme.dim, showing: $showing)
// Each arrangement, and what it was there to look at. The second half is only shown
// here: on the felt it would have told the player which habit was being counted while
// it was being counted.
VStack(alignment: .leading, spacing: 9) {
ForEach(report.steering) { steer in
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("\(steer.hands)").numericSmall().foregroundStyle(Theme.accent)
.frame(width: Theme.size(20), alignment: .trailing)
VStack(alignment: .leading, spacing: 2) {
Text(steer.setup.summary(atTableOf: TableSession.seatsAtTable))
.caption().foregroundStyle(Theme.text.opacity(0.85))
.fixedSize(horizontal: false, vertical: true)
GlossaryText(steer.setup.watching, size: TextRole.caption.base, colour: Theme.dim,
showing: $showing)
}
}
}
}
GlossaryText(report.fairnessNote, size: TextRole.body.base, colour: Theme.text.opacity(0.85),
showing: $showing)
}
}
}