← poker-tutor
import SwiftUI
import PokerCore
import PokerCoach
import PokerSession
/// What a drill is, before anybody agrees to sit through one.
///
/// Said up front for the same reason the assessment says its own awkward part up front: the table
/// is arranged rather than dealt, these hands are kept out of every number on the My game screen,
/// and somebody who reads that at the end and not at the beginning is entitled to feel they were
/// not told.
struct DrillBrief: View {
@Environment(AppState.self) var app
@Environment(\.metrics) var metrics
@State var showing: GlossaryTerm?
let target: DrillTarget
let retest: Bool
init(target: DrillTarget, retest: Bool) {
self.target = target
self.retest = retest
}
init(leak: Leak, retest: Bool) { self.init(target: DrillTarget(habit: leak), retest: retest) }
/// Asked for by target rather than by situation, because that is what the drill itself will
/// do. Asking by situation printed one seat here and dealt another at the table.
private var setup: AssessmentSetup { DrillPlanner.next(for: target) }
/// What the seat is worst at, for a seat drill; nil for a habit's own drill.
private var seatLeading: Leak? {
guard let seat = target.seat else { return nil }
return app.seatDiagnosis.report(for: seat)?.leading.first?.leak
}
private var hint: String {
if retest { return Copy.checkingBackHint }
return target.leak?.drillHint ?? Copy.seatDrillHint
}
private var explanation: String {
if let leak = target.leak { return leak.explanation }
if let leading = seatLeading {
return "From \(target.described) your decisions go wrong more often than from anywhere "
+ "else, mostly \(leading.name.lowercasedFirst). \(leading.explanation)"
}
return "From \(target.described) your decisions go wrong more often than from anywhere else."
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
Text(retest ? Copy.checkingBackTitle : Copy.drillTitle(target.name))
.title().foregroundStyle(Theme.text)
GlossaryText(hint, size: TextRole.caption.base, colour: Theme.dim, showing: $showing)
}
.padding(metrics.compact ? 16 : 22)
Divider().overlay(Color.white.opacity(0.07))
ScrollView {
VStack(alignment: .leading, spacing: 14) {
GlossaryText(explanation, size: TextRole.body.base, colour: Theme.text, showing: $showing)
if target.isSeat {
DrillPoint(title: Copy.sameSeat,
text: Copy.sameSeatBlurb(seat: target.described, lean: setup.lean.phrase),
showing: $showing)
DrillPoint(title: Copy.seatCounted, text: Copy.seatCountedBlurb, showing: $showing)
} else {
DrillPoint(title: Copy.sameSituation,
text: Copy.sameSituationBlurb(
seat: setup.seat.described(inTableOf: TableSession.seatsAtTable),
depthInBB: setup.depthInBB,
lean: setup.lean.phrase),
showing: $showing)
}
DrillPoint(title: Copy.cardsStillNotChosen,
text: Copy.cardsStillNotChosenBlurb,
showing: $showing)
DrillPoint(title: retest ? Copy.checkTakesAsLong : Copy.drillEndsWhenStopped,
text: retest
? Copy.checkTakesAsLongBlurb(DrillRule.retestChances)
: Copy.drillEndsWhenStoppedBlurb,
showing: $showing)
DrillPoint(title: Copy.drillHandsKeptOut,
text: Copy.drillHandsKeptOutBlurb,
showing: $showing)
DefinitionSlot(showing: $showing)
}
.padding(metrics.compact ? 18 : 22)
}
Divider().overlay(Color.white.opacity(0.07))
HStack(spacing: 10) {
PillButton(title: retest ? "Check it" : "Start drilling", wide: metrics.compact) {
app.beginDrill(target, retest: retest)
}
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.
struct DrillPoint: 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 a drill runs: what is being practised, how many chances it has
/// had, and what it would take to finish.
struct DrillBanner: 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: DrillRun
@Environment(AppState.self) var app
@Environment(\.metrics) var metrics
@State var showing: GlossaryTerm?
/// What a seat drill is watching for: the habit the seat is worst at, in lower case for the
/// middle of a sentence.
private var seatWatching: String? {
guard let seat = run.target.seat else { return nil }
return app.seatDiagnosis.report(for: seat)?.leading.first?.leak.name.lowercasedFirst
}
var body: some View {
VStack(alignment: .leading, spacing: 7) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(run.isRetest ? "Checking: \(run.target.name)" : Copy.drillTitle(run.target.name))
.caption(weight: .medium).foregroundStyle(Theme.accent)
Spacer(minLength: 8)
Text(run.progressLine).caption().foregroundStyle(Theme.dim)
}
ProgressStrip(fraction: run.fraction)
// The seat leads with its number here as it does everywhere else — "seat 2 of 9 (the
// big blind)" says the thing the traditional name was standing in for.
GlossaryText(run.target.isSeat
? Copy.seatBannerLine(run.target.name, watching: seatWatching)
: run.setup.introduction(atTableOf: TableSession.seatsAtTable),
size: 11, colour: Theme.text.opacity(0.85), showing: $showing)
if !metrics.compact {
GlossaryText(run.targetLine, size: TextRole.caption.base, colour: Theme.dim, showing: $showing)
}
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 }
}
}
/// What the drill concluded — including, often enough, that it did not clear.
struct DrillReportView: View {
@Environment(AppState.self) var app
@Environment(\.metrics) var metrics
@State var showing: GlossaryTerm?
let run: DrillRun
private var good: Bool { run.cleared || run.passed }
/// When the next check actually falls, counted off the hands that were just written down.
///
/// The panel used to be gated on `run.cleared`, which only a drill ever sets, so a check that
/// held up said "I will leave it a while longer and check once more" and then never said when —
/// on the one screen where the gap has just doubled and the player has no other way of knowing
/// it. The number is read from the diary rather than from `firstGapInDays`, which is right only
/// for the first check of the three.
private var nextCheckInDays: Int {
let book = app.drills
if let standing = book.standing(of: run.target.id), let due = standing.dueOn {
return max(1, standing.days(until: due, from: book.now))
}
return Int(DrillRule.gap(afterChecks: run.isRetest ? 1 : 0) / (24 * 60 * 60))
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
Text(run.outcomeTitle).title()
.foregroundStyle(good ? Theme.accent : Theme.warn)
Text(run.target.name).body().foregroundStyle(Theme.text)
}
.padding(metrics.compact ? 16 : 22)
Divider().overlay(Color.white.opacity(0.07))
ScrollView {
VStack(alignment: .leading, spacing: 18) {
Panel(title: Copy.whatHappened,
subtitle: Copy.drillHandsAndChances(hands: run.handsPlayed,
chances: run.chances)) {
// The counted rate, not the raw one, so the bar and the sentence under it
// are the same number.
RateBar(rate: run.countedRate, acceptable: run.target.acceptableRate)
GlossaryText(run.outcomeLine, size: TextRole.body.base, colour: Theme.text.opacity(0.9),
showing: $showing)
}
if run.cleared || run.passed {
Panel(title: Copy.willCheckBack,
subtitle: Copy.inAboutDays(nextCheckInDays)) {
// The check is announced — the home card names it, this screen names
// it, and a band above the felt names it while it runs. That is the
// right design: somebody dropped into a check without being told is
// just confused. So the copy says what the days are for, which is the
// part that actually does the work, rather than promising a surprise
// the app then does not spring.
GlossaryText(run.passed ? Copy.checkHeldBlurb : Copy.willCheckBackBlurb,
size: 12, colour: Theme.dim, showing: $showing)
}
}
if run.failed {
Panel(title: Copy.backOnYourList) {
GlossaryText(Copy.backOnYourListBlurb,
size: 12, colour: Theme.dim, showing: $showing)
}
}
if !run.isRetest, !run.cleared {
Panel(title: Copy.whatToDoAboutIt) {
GlossaryText(run.leak?.drillHint ?? Copy.seatDrillHint, size: TextRole.body.base, colour: Theme.dim,
showing: $showing)
GlossaryText(Copy.stoppingCostsNothing,
size: 12, colour: Theme.dim, showing: $showing)
}
}
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(640))
.frame(maxWidth: metrics.compact ? .infinity : nil,
maxHeight: metrics.compact ? .infinity : nil, alignment: .top)
.background(Theme.panel)
}
}
/// The home screen's drill card: a check that has come due, a drill left half finished, or the
/// habit worth working on next. Nothing at all where there is nothing honest to offer.
struct DrillCard: View {
@Environment(AppState.self) var app
@Environment(\.metrics) var metrics
@State var showing: GlossaryTerm?
var body: some View { content }
@ViewBuilder private var content: some View {
let book = app.drills
if let due = book.dueNow.first {
Panel(title: Copy.checkIsDue, subtitle: due.target.name) {
GlossaryText(due.line(now: book.now), size: TextRole.body.base, colour: Theme.dim, showing: $showing)
DefinitionSlot(showing: $showing)
HStack(spacing: 10) {
PillButton(title: "Check it",
subtitle: Copy.chancesAtTheSameSituation(DrillRule.retestChances),
wide: metrics.compact) {
app.offerDrill(due.target, retest: true)
}
if !metrics.compact { Spacer() }
}
}
} else if let open = book.unfinished {
Panel(title: Copy.drillHalfDone, subtitle: open.target.name) {
GlossaryText(open.line(now: book.now), size: TextRole.body.base, colour: Theme.dim, showing: $showing)
DefinitionSlot(showing: $showing)
HStack(spacing: 10) {
PillButton(title: "Carry on", wide: metrics.compact) { app.resumeDrill(open) }
PillButton(title: "Start it again", quiet: true,
wide: metrics.compact) {
app.offerDrill(open.target)
}
if !metrics.compact { Spacer() }
}
}
} else if let worst = app.focus.flatMap({ f in app.allTimeScorecard.costing.first { $0.id == f.id } }) {
// `focus` is the most expensive habit, or for a free player the one they are shown.
Panel(title: Copy.practiseOneThing, subtitle: worst.leak.name) {
GlossaryText(Copy.practiseOneThingBlurb,
size: 12, colour: Theme.dim, showing: $showing)
DefinitionSlot(showing: $showing)
HStack(spacing: 10) {
PillButton(title: "Drill this", wide: metrics.compact) {
app.offerDrill(worst.leak)
}
GhostButton(title: Copy.practiceSomethingElse, wide: metrics.compact) {
app.screen = .practice
}
if !metrics.compact { Spacer() }
}
}
} else {
// Nothing confirmed yet, which is every new player and most of the first evening.
// Before this the card simply vanished, so the only way to a drill was through a habit
// that takes hundreds of hands to appear.
Panel(title: Copy.practiceTitle, subtitle: Copy.practicePanelSubtitle) {
GlossaryText(Copy.practiceBlurb, size: TextRole.body.base, colour: Theme.dim, showing: $showing)
DefinitionSlot(showing: $showing)
HStack {
PillButton(title: Copy.practiceOpen, wide: metrics.compact) {
app.screen = .practice
}
if !metrics.compact { Spacer() }
}
}
}
}
}
/// The strip under a scorecard row that says where its drill stands, and offers to start one.
struct DrillStrip: View {
let entry: LeakScorecard.Entry
let now: Date
let start: (Leak) -> Void
@State var showing: GlossaryTerm?
private var tint: Color {
switch entry.drill?.stage {
case .holding, .due: return Theme.accent
case .reopened, .stalled: return Theme.warn
default: return Theme.gold
}
}
/// Says what the tap will actually do. `AppState` decides the same thing off the same standing,
/// so the two cannot say different things.
private var buttonTitle: String {
switch entry.drill?.stage {
case .none, .some(.notStarted): return "Drill this"
case .some(.running): return "Carry the drill on"
case .some(.due): return "Check it"
default: return "Drill it again"
}
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
if let drill = entry.drill {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Symbol(drill.isHeld ? "checkmark.seal" : "arrow.counterclockwise", size: Theme.size(10, capped: true))
.foregroundStyle(tint)
Text(drill.badge(now: now)).caption(weight: .medium).foregroundStyle(tint)
}
GlossaryText(drill.line(now: now), size: TextRole.caption.base, colour: Theme.dim, showing: $showing)
}
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)
}
Button { start(entry.leak) } label: {
Text(buttonTitle).caption(weight: .medium).foregroundStyle(Theme.background)
.padding(.horizontal, 12).padding(.vertical, 6)
.background(Capsule().fill(Theme.accent))
.contentShape(Capsule())
}
.buttonStyle(.plain)
DefinitionSlot(showing: $showing)
}
}
}