Advertisement

← poker-tutor

Sources/PokerUI/Screens.swift

35.8 KB Download file
import SwiftUI
import PokerCore
import PokerAI
import PokerCoach
import PokerSession

// MARK: - Home

struct HomeView: View {
    @Environment(AppState.self) var app
    @Environment(\.metrics) var metrics
    @State var showing: GlossaryTerm?

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                VStack(alignment: .leading, spacing: 4) {
                    Text(app.variant.displayName)
                        .font(.system(size: metrics.bigTitleSize, weight: .bold, design: .rounded))
                        .foregroundStyle(Theme.text)
                    Text(Copy.appTagline)
                        .body().foregroundStyle(Theme.dim)
                    AdLabMark(size: 12).padding(.top, 3)
                }
                .padding(.bottom, 4)

                // Said here rather than over the table. Interrupting a hand to mention a price is
                // the thing this app spent five phases learning not to do, and it only appears at
                // all once the end of the free hands is close.
                if app.shouldWarnAboutAllowance {
                    AllowanceNotice(handsLeft: app.handsLeft) { app.showPaywall() }
                }

                // The two things somebody opens the app to do, above everything the app has to
                // say about them. Ken, 2026-08-02: "lets put the start and sitdown buttons at the
                // top of the screen." He is right — the rating and the leak list are a report, and
                // a report should not stand between somebody and the table.
                // Which door this is: the tournament app offers a tournament, the cash app a
                // cash table set up the way the player wants it. Neither offers the other.
                if app.variant.isCash {
                    Panel(title: Copy.cashSitDownTitle, subtitle: app.ladder.placeLine) {
                        CashSetupPanel()
                    }
                } else {
                    Panel(title: "Sit down", subtitle: app.ladder.placeLine) {
                        HStack(spacing: 12) {
                            PillButton(title: "Tournament", subtitle: Copy.tournamentOffer,
                                       wide: metrics.compact) {
                                app.start(.tournament)
                            }
                            if !metrics.compact { Spacer() }
                        }
                    }
                }

                assessmentCard

                // Side by side where there is room; stacked on a phone.
                if metrics.compact {
                    ratingCard
                    focusCard
                } else {
                    HStack(alignment: .top, spacing: 16) {
                        ratingCard
                        focusCard
                    }
                }

                DrillCard()

                // Built from the hands this sitting actually wrote down, the way every other
                // screen builds its numbers, rather than from the coach's memory of everything
                // since the app opened.
                let sitting = app.sittingAnalysis
                if sitting.hands > 0 {
                    Panel(title: "This session",
                          subtitle: Copy.sittingLine(hands: sitting.hands,
                                                     flagged: sitting.flaggedDecisions)) {
                        SessionStrip(analysis: sitting, leaks: app.sittingLeaks.costing.count)
                    }
                }

                if app.history.hands.count >= 10 {
                    Panel(title: Copy.handsSavedTitle(saved: app.history.hands.count,
                                                      played: app.playedHands,
                                                      practice: app.practiceHands),
                          subtitle: Copy.handsSavedSubtitle) {
                        VStack(alignment: .leading, spacing: 10) {
                            Text(Copy.handsSavedBlurb)
                                .body().foregroundStyle(Theme.dim)
                                .fixedSize(horizontal: false, vertical: true)
                            HStack {
                                PillButton(title: "Look at my game", wide: metrics.compact) {
                                    app.scope = .allTime
                                    app.screen = .leaks
                                }
                                if !metrics.compact { Spacer() }
                            }
                        }
                    }
                }
            }
            .padding(metrics.gutter)
        }
        .background(Theme.background)
    }

    @ViewBuilder private var ratingCard: some View {
        // The caveat comes off at the hand the rating stops being held near the middle, and not
        // before: the number owns that bar, so the card cannot drift away from it.
        // And before there is a hand to rate, it says nothing at all. `SkillRating` clamps an empty
        // history to the middle of the scale on purpose — the ladder needs a number to place
        // somebody from — but the middle of a scale is not a finding, and printing it as one told a
        // player who had never played a hand that they were a solid amateur.
        let known = app.rating.handsBehindIt > 0
        Panel(title: "Your rating",
              subtitle: Copy.ratingSubtitle(handsBehindIt: app.rating.handsBehindIt,
                                            settled: app.rating.isSettled)) {
            HStack(alignment: .lastTextBaseline, spacing: 10) {
                Text(known ? "\(app.rating.value)" : "—")
                    .numeric(46, weight: .bold, capped: true)
                    .foregroundStyle(known ? Theme.accent : Theme.dim)
                VStack(alignment: .leading, spacing: 2) {
                    if known {
                        Text(Copy.ratingLabel(value: app.rating.value, label: app.rating.label))
                            .heading().foregroundStyle(Theme.text)
                    }
                    Text(Copy.ratingHandsWatched(app.rating.handsBehindIt))
                        .caption().foregroundStyle(Theme.dim)
                }
            }
            if known {
                Text(Copy.ratingCostLine(costPer100: app.rating.costPer100))
                    .caption().foregroundStyle(Theme.dim)
                    .fixedSize(horizontal: false, vertical: true)
            }
            RateBar(rate: Double(app.rating.value) / 100, acceptable: 0.8, height: 8, measured: known)
        }
    }

    /// The assessment: an offer before it has been done, a way back to the report afterwards, and
    /// a way to pick it up again if it was walked away from half way through.
    @ViewBuilder private var assessmentCard: some View {
        let unfinished = app.resumableAssessment
        let done = app.finishedAssessment
        Panel(title: Copy.assessmentFinishedTitle,
              subtitle: unfinished != nil
                ? Copy.assessmentStoppedAt(unfinished!.hands)
                : (done == nil
                   ? Copy.assessmentLength(AssessmentRun.standardLength)
                   : (done!.playedOn.map { Copy.assessmentPlayedOn($0) + " " } ?? "") + done!.headline)) {
            VStack(alignment: .leading, spacing: 10) {
                if let done, unfinished == nil {
                    Text(Copy.assessmentResult(rating: done.rating.value,
                                               label: done.rating.label,
                                               low: done.ratingRange.lowerBound,
                                               high: done.ratingRange.upperBound,
                                               hands: done.hands))
                        .body().foregroundStyle(Theme.dim)
                        .fixedSize(horizontal: false, vertical: true)
                } else {
                    Text(Copy.assessmentOffer)
                        .body().foregroundStyle(Theme.dim)
                        .fixedSize(horizontal: false, vertical: true)
                }
                HStack(spacing: 10) {
                    PillButton(title: app.assessmentInProgress
                                 ? "Back to it" : (unfinished != nil ? "Carry on" : "Start"),
                               wide: metrics.compact) {
                        app.offerAssessment()
                    }
                    if done != nil, unfinished == nil {
                        PillButton(title: "Read the report", quiet: true,
                                   wide: metrics.compact) {
                            app.showAssessmentReport()
                        }
                    }
                    if !metrics.compact { Spacer() }
                }
            }
        }
    }

    private var focusCard: some View {
        Panel(title: "What to work on", subtitle: app.focus == nil
              ? Copy.focusNone : Copy.focusWorst) {
            if let leak = app.focus {
                VStack(alignment: .leading, spacing: 7) {
                    Text(leak.name).heading().foregroundStyle(Theme.warn)
                    GlossaryText(leak.explanation, showing: $showing)
                    DefinitionSlot(showing: $showing)
                }
            } else {
                Text(app.freeView.isRestricted ? Copy.focusFreeEmpty : Copy.focusEmpty)
                    .body().foregroundStyle(Theme.dim).fixedSize(horizontal: false, vertical: true)
            }
        }
    }
}

struct SessionStrip: View {
    let analysis: GameAnalysis
    /// Habits confirmed off the same hands, counted where they are counted everywhere else.
    let leaks: Int
    @Environment(\.metrics) var metrics

    var body: some View {
        let items = [
            ("Hands", "\(analysis.hands)"),
            (metrics.compact ? "Flagged" : "Flagged decisions", "\(analysis.flaggedDecisions)"),
            // Floored. Three hands and one two-big-blind mistake is 66.7 per 100, and printing it
            // in the same type as everything else is the app overstating its own findings on the
            // one screen with no caveat under it. The panel's subtitle says why the dash is there.
            (metrics.compact ? "Cost /100" : "Cost per 100 hands", analysis.costPer100Firm),
            (metrics.compact ? "Leaks" : "Leaks confirmed", "\(leaks)"),
        ]
        if metrics.compact {
            LazyVGrid(columns: [GridItem(.flexible(), alignment: .leading),
                                GridItem(.flexible(), alignment: .leading)], spacing: 12) {
                ForEach(items, id: \.0) { stat($0.0, $0.1) }
            }
        } else {
            HStack(spacing: 26) {
                ForEach(items, id: \.0) { stat($0.0, $0.1) }
                Spacer()
            }
        }
    }

    private func stat(_ title: String, _ value: String) -> some View {
        VStack(alignment: .leading, spacing: 2) {
            Text(title.uppercased()).caption(weight: .medium).foregroundStyle(Theme.dim)
            Text(value).numeric(17).foregroundStyle(Theme.text)
        }
    }
}

// MARK: - Hand review

struct ReviewSheet: View {
    @Environment(AppState.self) var app
    @Environment(\.metrics) var metrics
    @State var showing: GlossaryTerm?
    let review: HandReview

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            VStack(alignment: .leading, spacing: 10) {
                HStack(alignment: .firstTextBaseline) {
                    Text("Hand \(review.handNumber)").title().foregroundStyle(Theme.text)
                    Spacer()
                    Text(review.netChips >= 0 ? "+\(review.netChips)" : "\(review.netChips)")
                        .numeric(19)
                        .foregroundStyle(review.netChips >= 0 ? Theme.accent : Theme.warn)
                }
                HStack(spacing: 14) {
                    HStack(spacing: 4) {
                        Text("You had").body().foregroundStyle(Theme.dim)
                        ForEach(Array(review.holeCards.enumerated()), id: \.offset) { _, card in
                            CardView(card: card, size: 34)
                        }
                    }
                    if !review.board.isEmpty {
                        HStack(spacing: 4) {
                            TermChip("the board", showing: $showing, display: "Board", size: 12)
                            ForEach(Array(review.board.enumerated()), id: \.offset) { _, card in
                                CardView(card: card, size: 34)
                            }
                        }
                    }
                }
                GlossaryText(review.summary, size: TextRole.body.base,
                             colour: review.playedWell ? Theme.accent : Theme.gold,
                             showing: $showing)
                DefinitionSlot(showing: $showing)
            }
            .padding(metrics.compact ? 16 : 22)

            Divider().overlay(Color.white.opacity(0.07))

            ScrollView {
                VStack(alignment: .leading, spacing: 14) {
                    // This sheet opens by itself only on a hand the coach spoke about, so nothing
                    // here makes it speak more often. What it stops doing is telling a player who
                    // opened it themselves that there was nothing in a hand that the scorecard
                    // counted a habit from.
                    if review.playedWell {
                        Text(review.countedQuietly.isEmpty
                             ? Copy.nothingToFlag
                             : Copy.nothingFlaggedButCounted(review.countedQuietly.count))
                            .body(Theme.coachBase(TextRole.body.base)).foregroundStyle(Theme.dim)
                            .fixedSize(horizontal: false, vertical: true)
                    }
                    ForEach(Array(review.worthMentioning.enumerated()), id: \.offset) { _, verdict in
                        VerdictCard(verdict: verdict)
                    }
                }
                .padding(22)
            }
            // Grows with the type, so the review still shows about as much of itself as it did
            // before — otherwise larger text just means more scrolling to read the same verdicts.
            .frame(maxHeight: metrics.compact ? .infinity : Theme.size(300))

            Divider().overlay(Color.white.opacity(0.07))

            HStack {
                if !metrics.compact {
                    Text(Copy.quietOnCloseSpots)
                        .body(Theme.coachBase(TextRole.caption.base)).foregroundStyle(Theme.dim)
                    Spacer()
                }
                // Closes the review and nothing else. Dealing the next hand is what closing the
                // review MEANS now, wherever the closing came from — see `AppState.sheetDismissed`.
                PillButton(title: "Next hand", wide: metrics.compact) { app.dismissSheet() }
            }
            .padding(18)
        }
        // Grows with the type. Left at a fixed width, larger text only wrapped tighter inside the
        // same box — which reads as cramped rather than as bigger, on the one piece of writing in
        // the app that exists to be read carefully.
        .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 flagged decision. Built either from a verdict the coach has just reached, or from one read
/// back off disk months later — they have to look identical, so there is one card.
struct VerdictCard: View {
    let street: String
    let spot: String
    let headline: String
    let detail: String
    let taken: String
    let best: String
    let costInBB: Double
    let severity: Severity
    /// The lesson that explains the rule this decision was marked against, where one exists.
    let lesson: BasicLesson?
    @State var showing: GlossaryTerm?
    @State var showingLesson = false

    init(verdict: Verdict) {
        street = verdict.street.name
        spot = verdict.spot.name
        headline = verdict.headline
        detail = verdict.detail
        taken = "\(verdict.taken)"
        best = "\(verdict.best)"
        costInBB = verdict.costInBB
        severity = verdict.severity
        lesson = verdict.error.flatMap { Basics.lesson(spot: verdict.spot, error: $0) }
    }

    init(record: VerdictRecord) {
        street = record.street
        spot = record.spot
        headline = record.headline
        detail = record.detail
        taken = record.taken
        best = record.best
        costInBB = record.costInBB
        severity = Severity(rawValue: record.severity) ?? .minor
        if let spotKind = record.spotKind, let errorKind = record.errorKind {
            lesson = Basics.lesson(spot: spotKind, error: errorKind)
        } else {
            lesson = nil
        }
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            HStack(spacing: 8) {
                Text(street.capitalized).caption(weight: .medium)
                    .foregroundStyle(Theme.background)
                    .padding(.horizontal, 6).padding(.vertical, 2)
                    .background(Capsule().fill(Theme.severityColour(severity)))
                GlossaryText(spot, size: TextRole.caption.base, showing: $showing)
                Spacer()
                Text(String(format: "%.1f bb", costInBB))
                    .numericSmall().foregroundStyle(Theme.severityColour(severity))
            }

            Text(headline).heading(Theme.coachBase(TextRole.heading.base)).foregroundStyle(Theme.text)
            GlossaryText(detail, showing: $showing)

            HStack(spacing: 16) {
                labelled("You", taken, Theme.warn)
                labelled("Better", best, Theme.accent)
            }
            DefinitionSlot(showing: $showing)

            // Being told a decision was wrong without being told the rule behind it is the
            // complaint that put this here. It opens in place rather than over the top: the review
            // is already a sheet, and a lesson raised over it would be a sheet on a sheet on a
            // phone. The same interaction as tapping a poker word two lines above.
            if let lesson {
                Divider().overlay(Color.white.opacity(0.07)).padding(.top, 2)
                Button {
                    withAnimation(.easeOut(duration: 0.18)) { showingLesson.toggle() }
                } label: {
                    HStack(spacing: 6) {
                        Symbol(showingLesson ? "chevron.down" : "chevron.right", size: Theme.size(10))
                        Text(lesson.question).caption(weight: .medium)
                        Spacer()
                    }
                    .foregroundStyle(Theme.accent)
                    .contentShape(Rectangle())
                }
                .buttonStyle(.plain)

                if showingLesson { LessonBody(lesson: lesson, showing: $showing) }
            }
        }
        .padding(15)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(RoundedRectangle(cornerRadius: 11, style: .continuous).fill(Theme.panelRaised))
    }

    private func labelled(_ title: String, _ value: String, _ colour: Color) -> some View {
        HStack(spacing: 5) {
            Text(title.uppercased()).caption(weight: .medium).foregroundStyle(Theme.dim)
            Text(value).heading().foregroundStyle(colour)
        }
    }
}

/// A decision the coach kept quiet about and the scorecard counted anyway.
///
/// The two bars are different on purpose and the higher one is not up for negotiation: the coach
/// stops the game only for a mistake worth stopping it over. What this fixes is the other end —
/// three screens that took the coach's silence and turned it into a claim. The Hands list called
/// the hand clean, the detail said "no comment" against the decision, and the replay said calling
/// it a mistake would be guessing, all about decisions sitting underneath a named habit on the My
/// game screen.
struct CountedCard: View {
    let verdict: VerdictRecord

    /// The habit this decision is counted under, where the file still names one this build knows.
    private var leakName: String? {
        guard let spot = verdict.spotKind, let error = verdict.errorKind else { return nil }
        return LeakCatalogue.matching(spot: spot, error: error)?.name
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            HStack(spacing: 10) {
                Text(verdict.street.capitalized).caption(weight: .medium).foregroundStyle(Theme.dim)
                    .frame(width: Theme.size(60), alignment: .leading)
                Text("you \(verdict.taken)").body().foregroundStyle(Theme.text)
                Spacer(minLength: 0)
            }
            Text(leakName.map(Copy.countedTowards) ?? Copy.countedTowardsUnnamed)
                .caption().foregroundStyle(Theme.gold.opacity(0.9))
                .fixedSize(horizontal: false, vertical: true)
        }
        .padding(13)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(RoundedRectangle(cornerRadius: 11, style: .continuous)
            .fill(Theme.panelRaised.opacity(0.6)))
    }
}

// MARK: - Leak scorecard

struct LeaksView: View {
    @Environment(AppState.self) var app
    @Environment(\.metrics) var metrics

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                heading
                ScopeBar()
                scorecardPanels
                numbers
            }
            .padding(metrics.gutter)
        }
        .background(Theme.background)
    }

    private var heading: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("My game")
                .font(.system(size: metrics.titleSize, weight: .bold, design: .rounded))
                .foregroundStyle(Theme.text)
            Text(Copy.myGameIntro)
                .body().foregroundStyle(Theme.dim).fixedSize(horizontal: false, vertical: true)
        }
    }

    /// What is costing money, what is being kept an eye on, and the full list — all three read off
    /// the same `LeakScorecard`, and each row able to turn itself into a drill.
    @ViewBuilder private var scorecardPanels: some View {
        let scorecard = app.scorecard

        let free = app.freeView

        Panel(title: "What is costing you",
              subtitle: Copy.costingYouSubtitle) {
            ScorecardBoard(scorecard: scorecard, drill: { leak in app.startOrResumeDrill(leak) },
                           free: free, showPrice: { app.showPaywall() })
        }

        if !scorecard.watching.isEmpty {
            Panel(title: "Too soon to call",
                  subtitle: Copy.tooSoonSubtitle) {
                VStack(spacing: 18) {
                    // With nothing proved yet, the free row lives here, and says it is not proved.
                    if free.isRestricted, free.hiddenCount > 0, free.basis == .watching {
                        HiddenCountLine(count: free.hiddenCount) { app.showPaywall() }
                    }
                    ForEach(scorecard.watching) { entry in
                        ScorecardRow(entry: entry, now: scorecard.asOf,
                                     drill: { leak in app.startOrResumeDrill(leak) },
                                     freeLabel: free.isRestricted && entry.id == free.visibleLeakID
                                        ? Copy.freeRowNotProved : nil)
                            .hiddenUntilSubscribed(free.isBlurred(leakID: entry.id)) { app.showPaywall() }
                    }
                }
            }
        }

        Panel(title: "Everything measured", subtitle: Copy.everythingMeasuredSubtitle) {
            ScorecardIndex(scorecard: scorecard, free: free, showPrice: { app.showPaywall() })
        }
    }

    @ViewBuilder private var numbers: some View {
        let analysis = app.analysis
        let scorecard = app.scorecard

        Panel(title: "The numbers", subtitle: analysis.sessions > 1
              ? Copy.numbersAcross(analysis.sessions) : Copy.numbersMeasured) {
            AnalysisSummary(analysis: analysis)
            // Assessment and drill hands are arranged rather than dealt, so they are left out of
            // these. Said here rather than left for somebody to notice the numbers do not add up.
            if let note = analysis.arrangedNote {
                Text(note).caption().foregroundStyle(Theme.gold.opacity(0.9))
                    .fixedSize(horizontal: false, vertical: true)
            }
        }

        if !analysis.byPosition.isEmpty {
            Panel(title: "By seat", subtitle: Copy.bySeatSubtitle) {
                PositionTable(analysis: analysis)
            }
            WhereYouLoseMost()
        }

        Panel(title: "What it cost", subtitle: Copy.whatItCostSubtitle) {
            CostBreakdown(scorecard: scorecard, analysis: analysis)
        }

        if analysis.best != nil || analysis.worst != nil {
            Panel(title: "Worth another look", subtitle: Copy.worthAnotherLookSubtitle) {
                NotableHands(analysis: analysis) { _ in app.screen = .history }
            }
        }
    }
}

// MARK: - Hand history

/// Every hand ever played, filtered to whichever sitting is being looked at.
struct HistoryView: View {
    @Environment(AppState.self) var app
    @Environment(\.metrics) var metrics
    @State var selected: String?
    @State var flaggedOnly = false
    /// Whether the hand on the right is being stepped through rather than summarised. Reset
    /// whenever a different hand is picked — landing on hand 40 halfway through hand 39's river
    /// is not something anybody asked for.
    @State var replaying = false

    private var shown: [HandRecord] {
        let hands = app.scopedHands.reversed()
        return flaggedOnly ? hands.filter { !$0.playedWell } : Array(hands)
    }

    var body: some View {
        Group {
            if metrics.compact {
                // No room for two panes: the list is the screen, and a hand opens over it.
                if selected != nil {
                    VStack(alignment: .leading, spacing: 0) {
                        Button { selected = nil; replaying = false } label: {
                            HStack(spacing: 4) {
                                Symbol("chevron.left")
                                Text("All hands")
                            }
                            // On the whole row, so the chevron grows with the words beside it.
                            .font(.system(size: Theme.size(13), weight: .regular, design: .rounded))
                            .foregroundStyle(Theme.dim)
                            .frame(minHeight: 36)
                            .contentShape(Rectangle())
                        }
                        .buttonStyle(.plain)
                        .padding(.horizontal, metrics.gutter)
                        detail
                    }
                } else {
                    list
                }
            } else {
                HStack(spacing: 0) {
                    list.frame(width: Theme.size(340))
                    Divider().overlay(Color.white.opacity(0.07))
                    detail
                }
            }
        }
        .background(Theme.background)
    }

    private var list: some View {
        VStack(alignment: .leading, spacing: 10) {
            VStack(alignment: .leading, spacing: 8) {
                ScopeBar()
                Toggle(isOn: $flaggedOnly) {
                    Text(Copy.flaggedOnlyToggle).caption().foregroundStyle(Theme.dim)
                }
                .toggleStyle(.switch)
                .tint(Theme.accent)
                .miniControl()
            }
            .padding(.horizontal, metrics.gutter)
            .padding(.top, metrics.gutter)

            if shown.isEmpty {
                VStack(alignment: .leading, spacing: 6) {
                    Text(app.scopedHands.isEmpty ? Copy.noHandsYet : Copy.nothingFlagged)
                        .heading().foregroundStyle(Theme.text)
                    Text(app.scopedHands.isEmpty ? Copy.noHandsYetBlurb : Copy.nothingFlaggedBlurb)
                        .body().foregroundStyle(Theme.dim).fixedSize(horizontal: false, vertical: true)
                }
                .padding(.horizontal, metrics.gutter)
                Spacer()
            } else {
                List(shown) { hand in
                    Button { selected = hand.id; replaying = false } label: { row(hand) }
                        .buttonStyle(.plain)
                        .listRowBackground(selected == hand.id ? Theme.panelRaised : Color.clear)
                }
                .scrollContentBackground(.hidden)
            }
        }
        .background(Theme.panel)
    }

    private func row(_ hand: HandRecord) -> some View {
        HStack(spacing: 10) {
            VStack(alignment: .leading, spacing: 2) {
                HStack(spacing: 5) {
                    Text("Hand \(hand.handNumber)").body().foregroundStyle(Theme.text)
                    Text(hand.seatPhrase).caption(weight: .medium).foregroundStyle(Theme.dim)
                    // Which of these were arranged, on the row rather than only once the hand is
                    // open. Ninety drill hands in a row are otherwise ninety rows that look like
                    // ordinary poker, and they are the reason the count on the scope bar above and
                    // the hands tile on My game are two different numbers.
                    if hand.isArranged {
                        Text(hand.mode).caption(weight: .medium).foregroundStyle(Theme.gold.opacity(0.9))
                    }
                }
                // Three states rather than two. A hand can be one the coach kept quiet on and still
                // be one the scorecard counted a habit from, and calling that "clean" is the app
                // denying its own finding on the screen somebody opened to check it.
                Text(Copy.handBadge(flagged: hand.flagged.count, counted: hand.counted.count))
                    .caption(weight: .medium)
                    .foregroundStyle(hand.playedWell
                                     ? (hand.nothingCounted ? Theme.accent : Theme.gold.opacity(0.8))
                                     : Theme.gold)
            }
            Spacer()
            HStack(spacing: 2) {
                ForEach(Array(hand.cards.enumerated()), id: \.offset) { _, card in
                    CardView(card: card, size: 24)
                }
            }
            Text(hand.netChips >= 0 ? "+\(hand.netChips)" : "\(hand.netChips)")
                .numericSmall()
                .foregroundStyle(hand.netChips >= 0 ? Theme.accent : Theme.warn)
                .frame(width: Theme.size(58), alignment: .trailing)
        }
        .padding(.vertical, 3)
    }

    private var detail: some View {
        ScrollView {
            if let hand = shown.first(where: { $0.id == selected })
                ?? app.scopedHands.first(where: { $0.id == selected }) {
                VStack(alignment: .leading, spacing: 14) {
                    HStack(alignment: .firstTextBaseline) {
                        Text("Hand \(hand.handNumber)").title().foregroundStyle(Theme.text)
                        Spacer()
                        Text(hand.netChips >= 0 ? "+\(hand.netChips)" : "\(hand.netChips)")
                            .numeric(18)
                            .foregroundStyle(hand.netChips >= 0 ? Theme.accent : Theme.warn)
                    }
                    Text("\(hand.mode) · \(hand.seatDescription) · "
                         + "blinds \(hand.smallBlind ?? hand.bigBlind / 2)/\(hand.bigBlind) · "
                         + hand.playedAt.formatted(date: .abbreviated, time: .shortened))
                        .caption().foregroundStyle(Theme.dim)

                    HStack(spacing: 4) {
                        ForEach(Array(hand.cards.enumerated()), id: \.offset) { _, card in
                            CardView(card: card, size: 40)
                        }
                        Spacer().frame(width: 14)
                        ForEach(Array(hand.boardCards.enumerated()), id: \.offset) { _, card in
                            CardView(card: card, size: 40)
                        }
                    }

                    if hand.canReplay {
                        HStack {
                            PillButton(title: replaying ? "Back to the summary" : "Play this hand back",
                                       subtitle: replaying ? nil : Copy.replayOffer,
                                       tint: replaying ? Theme.hero : Theme.accent,
                                       wide: metrics.compact) {
                                replaying.toggle()
                            }
                            if !metrics.compact { Spacer() }
                        }
                    }

                    if replaying, hand.canReplay {
                        HandReplayView(record: hand) { replaying = false }
                    } else {
                        if let line = Copy.handSummary(flagged: hand.flagged.count,
                                                       counted: hand.counted.count) {
                            Text(line)
                                .body()
                                .foregroundStyle(hand.nothingCounted ? Theme.accent : Theme.gold)
                                .fixedSize(horizontal: false, vertical: true)
                        }

                        ForEach(Array(hand.verdicts.enumerated()), id: \.offset) { _, verdict in
                            switch Copy.decisionNote(spoke: verdict.spoke,
                                                     countsTowardsLeak: verdict.countsTowardsLeak) {
                            case .flagged:
                                VerdictCard(record: verdict)
                            case .counted:
                                CountedCard(verdict: verdict)
                            case .tooClose:
                                HStack(spacing: 10) {
                                    Text(verdict.street.capitalized).caption(weight: .medium).foregroundStyle(Theme.dim)
                                        .frame(width: 60, alignment: .leading)
                                    Text("you \(verdict.taken)").body().foregroundStyle(Theme.dim)
                                    Spacer()
                                    Text(Copy.noComment).caption().foregroundStyle(Theme.dim.opacity(0.6))
                                }
                                .padding(.vertical, 4)
                            }
                        }
                    }
                }
                .padding(metrics.compact ? metrics.gutter : 24)
            } else {
                VStack(alignment: .leading, spacing: 8) {
                    Text(Copy.pickAHand).heading().foregroundStyle(Theme.text)
                    Text(Copy.pickAHandBlurb)
                        .body().foregroundStyle(Theme.dim)
                }
                .padding(24)
            }
        }
        .frame(maxWidth: .infinity, alignment: .topLeading)
    }
}