Advertisement

← poker-tutor

Sources/PokerUI/PracticeView.swift

9.6 KB Download file
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))
    }
}