← poker-tutor
import SwiftUI
import PokerCore
import PokerCoach
import PokerSession
/// Every drill the app has, in one place, startable whenever somebody feels like it.
///
/// Ken, 2026-08-02: "How do I get to the drills? Is there a way to navigate to them?" There was
/// not. Drills were built to close a habit the coach had *found*, so every door into one was
/// behind a confirmed habit — which takes hundreds of hands to appear. Somebody who simply wanted
/// to practise the thing they knew they were bad at had nowhere to go.
///
/// The coach's restraint rule is untouched by this. That rule is about what the app *says about
/// you* unprompted, and it still refuses to name a habit it has not measured. Choosing to practise
/// something is the player's decision about their own time, and nothing here claims they have a
/// problem — the list says what each drill deals, not what is wrong with them.
struct PracticeView: View {
@Environment(AppState.self) var app
@Environment(\.metrics) var metrics
@State var showing: GlossaryTerm?
/// What the app can actually drill. `bet-sizing` is in the catalogue but deliberately unnameable
/// — the two populations overlap too far to judge it — and a drill it can never clear would be
/// a treadmill, so it stays off this list even though its lesson in the basics does not.
private var drillable: [Leak] { LeakCatalogue.all.filter(\.canBeNamed) }
/// Grouped by when in the hand it happens, because that is how somebody thinks about what they
/// want to work on: "I am bad before the flop" rather than "I am bad at leak seven".
private var groups: [(String, [Leak])] {
let order: [(String, [SpotKind])] = [
(Copy.practiceBeforeFlop, [.openingChance, .facingRaise, .facingReraise,
.bigBlindDefence, .shortStackShove]),
(Copy.practiceOnTheFlop, [.continuationBet, .facingContinuationBet, .flopDecision]),
(Copy.practiceLaterStreets, [.turnDecision, .riverDecision, .facingRiverBet]),
]
return order.compactMap { title, spots in
let leaks = drillable.filter { spots.contains($0.spot) }
return leaks.isEmpty ? nil : (title, leaks)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider().overlay(Color.white.opacity(0.07))
ScrollView {
VStack(alignment: .leading, spacing: 18) {
ForEach(Array(groups.enumerated()), id: \.offset) { _, group in
VStack(alignment: .leading, spacing: 10) {
Text(group.0.uppercased())
.caption(weight: .medium).foregroundStyle(Theme.gold.opacity(0.9))
ForEach(group.1) { leak in
row(leak)
}
}
}
seatDrills
Text(Copy.practiceFooter)
.body(Theme.coachBase(TextRole.caption.base)).foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
}
.padding(metrics.gutter)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Theme.background)
}
private var header: some View {
VStack(alignment: .leading, spacing: 4) {
Text(Copy.practiceTitle)
.font(.system(size: metrics.titleSize, weight: .bold, design: .rounded))
.foregroundStyle(Theme.text)
Text(Copy.practiceSubtitle)
.body(Theme.coachBase(TextRole.body.base)).foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
if app.freeView.isRestricted {
Text(Copy.practiceFreeNote)
.caption(weight: .medium).foregroundStyle(Theme.gold)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(metrics.gutter)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Theme.panel)
}
/// Drills by seat: the seat the diagnosis names first, then any seat already drilled, then
/// every other seat at the table. Ken, 2026-09-11: "let them drill while playing only that
/// position."
@ViewBuilder private var seatDrills: some View {
let diagnosis = app.seatDiagnosis
let book = app.drills
let size = TableSession.seatsAtTable
let named = diagnosis.worst?.seat
let ordered = Position.names(forPlayerCount: size).sorted { a, b in
if a == named { return true }
if b == named { return false }
let (x, y) = (book.standing(ofSeat: a) != nil, book.standing(ofSeat: b) != nil)
return x != y ? x : false
}
VStack(alignment: .leading, spacing: 10) {
Text(Copy.seatDrillsHeading.uppercased())
.caption(weight: .medium).foregroundStyle(Theme.gold.opacity(0.9))
Text(Copy.seatDrillsFooter)
.caption().foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
ForEach(ordered, id: \.rawValue) { seat in
seatRow(seat, size: size, isWorst: seat == named)
}
}
}
private func seatRow(_ seat: Position, size: Int, isWorst: Bool) -> some View {
let standing = app.drills.standing(ofSeat: seat)
let locked = !app.mayDrillSeat(seat)
let report = app.seatDiagnosis.report(for: seat)
let phrase = seat.seatPhrase(inTableOf: size)
return VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(phrase.prefix(1).uppercased() + phrase.dropFirst() + " (\(seat.fullName))")
.heading(Theme.coachBase(TextRole.heading.base)).foregroundStyle(Theme.text)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
if isWorst {
Text("Where you lose most")
.caption(weight: .medium).foregroundStyle(Theme.background)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Capsule().fill(Theme.gold))
} else if let stage = standing?.stage, let mark = Copy.practiceMark(stage) {
Text(mark)
.caption(weight: .medium).foregroundStyle(Theme.background)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Capsule().fill(Theme.accent))
}
}
if let leading = report?.leading.first {
GlossaryText("Mostly \(leading.leak.name.lowercasedFirst) there.", size: TextRole.caption.base, showing: $showing)
} else {
GlossaryText(Copy.seatDrillHint, size: TextRole.caption.base, showing: $showing)
}
HStack {
GhostButton(title: locked ? Copy.practiceLocked : Copy.practiceStart, wide: metrics.compact) {
app.startOrResumeSeatDrill(seat) // shows the price where the drill is locked
}
if !metrics.compact { Spacer() }
}
}
.opacity(locked ? 0.55 : 1)
.padding(13)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 11, style: .continuous).fill(Theme.panel))
}
private func row(_ leak: Leak) -> some View {
// What the app already knows about this one, if anything. A habit the coach has confirmed
// says so here rather than making somebody go and look — but a habit it has not is simply
// silent, which is the same restraint every other screen keeps.
let standing = app.drills.standing(of: leak.id)
let locked = !app.freeView.mayDrill(leakID: leak.id)
return VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(leak.name)
.heading(Theme.coachBase(TextRole.heading.base)).foregroundStyle(Theme.text)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
if let stage = standing?.stage, let mark = Copy.practiceMark(stage) {
Text(mark)
.caption(weight: .medium).foregroundStyle(Theme.background)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Capsule().fill(Theme.accent))
}
}
GlossaryText(leak.drillHint, size: TextRole.caption.base, showing: $showing)
HStack {
GhostButton(title: locked ? Copy.practiceLocked : Copy.practiceStart, wide: metrics.compact) {
app.startOrResumeDrill(leak) // shows the price where the drill is locked
}
if !metrics.compact { Spacer() }
}
}
.opacity(locked ? 0.55 : 1)
.padding(13)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 11, style: .continuous).fill(Theme.panel))
}
}