Advertisement

← poker-tutor

Sources/PokerUI/LadderView.swift

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

/// The ladder: which room you are in, which one is next, what it takes to get there, and what your
/// recent hands actually say about it.
///
/// Every number and every sentence on this screen comes out of `LadderStanding`, which is in
/// `PokerSession` and covered by tests. This file only decides where the words go. That is the same
/// division as everywhere else in the app, and here it earns itself twice over: the promotion rules
/// are the part that can be quietly wrong, and a rule you can only reach by clicking is a rule
/// nobody can check.
struct LadderView: View {
    @Environment(AppState.self) var app
    @Environment(\.metrics) var metrics

    var body: some View {
        let standing = app.ladder
        return ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                header
                WhereYouAreCard(standing: standing)
                NextRungCard(standing: standing)
                if standing.costliest != nil {
                    CostliestCard(standing: standing)
                }
                RungList(standing: standing)
            }
            .padding(metrics.gutter)
        }
        .background(Theme.background)
    }

    private var header: some View {
        VStack(alignment: .leading, spacing: 5) {
            Text("The ladder")
                .font(.system(size: metrics.titleSize, weight: .bold, design: .rounded))
                .foregroundStyle(Theme.text)
            Text(Copy.ladderIntro(LadderTier.all.count))
                .body().foregroundStyle(Theme.dim)
                .fixedSize(horizontal: false, vertical: true)
        }
    }
}

// MARK: - Where you are

struct WhereYouAreCard: View {
    let standing: LadderStanding
    @Environment(\.metrics) var metrics
    @State var showing: GlossaryTerm?

    var body: some View {
        Panel(title: standing.settled.name, subtitle: standing.placeLine) {
            VStack(alignment: .leading, spacing: 12) {
                if standing.move != .held { moveBadge }

                if metrics.compact {
                    VStack(alignment: .leading, spacing: 12) { room; table }
                } else {
                    HStack(alignment: .top, spacing: 18) { room; table }
                }

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

                VStack(alignment: .leading, spacing: 7) {
                    GlossaryText(standing.resultsLine, size: TextRole.body.base, colour: Theme.text, showing: $showing)
                    if let winRate = standing.winRateLine {
                        GlossaryText(winRate, size: TextRole.body.base, colour: Theme.text, showing: $showing)
                    }
                    RateBar(rate: standing.fraction, acceptable: 1, height: 7)
                    Text(Copy.handsAtThisTable(played: standing.handsAtTier,
                                               needed: LadderStanding.sampleSize))
                        .caption().foregroundStyle(Theme.dim)
                    GlossaryText(standing.scopeNote, size: TextRole.caption.base,
                                 colour: Theme.dim.opacity(0.85), showing: $showing)
                    GlossaryText(standing.meaningNote, size: TextRole.caption.base,
                                 colour: Theme.dim.opacity(0.85), showing: $showing)
                }
                DefinitionSlot(showing: $showing)
            }
        }
    }

    private var moveBadge: some View {
        let promoted = standing.move == .promoted
        return HStack(spacing: 8) {
            Text(promoted ? "MOVED UP" : "MOVED BACK")
                .caption(weight: .medium).foregroundStyle(Theme.background)
                .padding(.horizontal, 8).padding(.vertical, 4)
                .background(Capsule().fill(promoted ? Theme.accent : Theme.warn))
            Text(promoted ? Copy.nextTableIs(standing.tier.inSentence)
                           : Copy.nextTableIsAgain(standing.tier.inSentence))
                .body().foregroundStyle(Theme.text)
        }
    }

    private var room: some View {
        VStack(alignment: .leading, spacing: 8) {
            GlossaryText(standing.settled.blurb, size: TextRole.body.base, colour: Theme.dim, showing: $showing)
            CastRow(tier: standing.settled, showing: $showing)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
    }

    /// The room drawn out, with every chair numbered.
    ///
    /// The numbers are the point. "Nine handed" is a fact about a table nobody can see, and the
    /// same ring is what the rest of the app uses to answer where you are sitting and how many
    /// people speak after you. The seats rotate every hand, so nobody's style is pinned to a
    /// number here — this says how big the room is and nothing more.
    private var table: some View {
        VStack(alignment: .leading, spacing: 6) {
            TableDiagram(players: TableSession.seatsAtTable, base: 132)
            Text(Copy.nineSeatsBlurb)
                .caption().foregroundStyle(Theme.dim.opacity(0.85))
                .fixedSize(horizontal: false, vertical: true)
                .frame(maxWidth: 260, alignment: .leading)
        }
    }
}

// MARK: - What is next

struct NextRungCard: View {
    let standing: LadderStanding
    @State var showing: GlossaryTerm?

    var body: some View {
        let next = standing.settled.next
        return Panel(title: next == nil ? Copy.topOfTheLadder : Copy.whatItTakesToMoveUp,
                     subtitle: next?.name) {
            VStack(alignment: .leading, spacing: 10) {
                GlossaryText(standing.requirementLine, size: TextRole.body.base, colour: Theme.text, showing: $showing)
                if let next {
                    GlossaryText(next.blurb, size: TextRole.body.base, colour: Theme.dim, showing: $showing)
                    CastRow(tier: next, showing: $showing)
                    GlossaryText(whatChanges(to: next), size: TextRole.body.base,
                                 colour: Theme.dim, showing: $showing)
                }
                if let line = standing.demotionLine {
                    GlossaryText(line, size: TextRole.caption.base, colour: Theme.dim.opacity(0.85), showing: $showing)
                }
                DefinitionSlot(showing: $showing)
            }
        }
    }

    /// The honest version of "what is different up there": who leaves and who takes their chair.
    /// It is written from the two rosters rather than typed out, so it cannot drift away from the
    /// table the player actually gets sat at.
    private func whatChanges(to next: LadderTier) -> String {
        var leaving: [Personality: Int] = [:]
        var arriving: [Personality: Int] = [:]
        for style in Personality.allCases {
            let before = standing.settled.roster.filter { $0 == style }.count
            let after = next.roster.filter { $0 == style }.count
            if after < before { leaving[style] = before - after }
            if after > before { arriving[style] = after - before }
        }
        let out = list(leaving)
        let inn = list(arriving)
        guard !out.isEmpty || !inn.isEmpty else {
            return Copy.sameEightPeople
        }
        return Copy.whoChanges(leaving: out, arriving: inn)
    }

    private func list(_ counts: [Personality: Int]) -> String {
        let parts = Personality.allCases.compactMap { style in
            counts[style].map { style.counted($0) }
        }
        guard let last = parts.last else { return Copy.nobody }
        if parts.count == 1 { return last }
        return parts.dropLast().joined(separator: ", ") + " and " + last
    }
}

// MARK: - Where the money goes at this table

struct CostliestCard: View {
    let standing: LadderStanding
    @State var showing: GlossaryTerm?

    var body: some View {
        Panel(title: Copy.costingAtThisTable,
              subtitle: standing.costliest?.leak.name) {
            VStack(alignment: .leading, spacing: 8) {
                if let line = standing.costliestLine {
                    GlossaryText(line, size: TextRole.body.base, colour: Theme.text, showing: $showing)
                }
                if let leak = standing.costliest?.leak {
                    GlossaryText(leak.explanation, size: TextRole.body.base, colour: Theme.dim, showing: $showing)
                }
                DefinitionSlot(showing: $showing)
            }
        }
    }
}

// MARK: - The whole ladder

struct RungList: View {
    let standing: LadderStanding
    @Environment(\.metrics) var metrics
    @State var showing: GlossaryTerm?

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text(Copy.everyRung).heading().foregroundStyle(Theme.text)
            ForEach(LadderTier.all) { tier in
                row(tier)
            }
            DefinitionSlot(showing: $showing)
        }
        .padding(18)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(Theme.panel))
        .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous)
            .stroke(Color.white.opacity(0.05), lineWidth: 1))
    }

    /// One rung. A rung above the player is simply somewhere they have not been sat yet, which is
    /// why none of them is called locked: locked would suggest there is a key.
    private func row(_ tier: LadderTier) -> some View {
        let here = tier.id == standing.settled.id
        let next = tier.id == standing.tier.id && standing.move != .held
        return VStack(alignment: .leading, spacing: 6) {
            HStack(alignment: .firstTextBaseline, spacing: 8) {
                Text("\(tier.rung + 1)").numeric().foregroundStyle(Theme.dim)
                    .frame(width: Theme.size(16), alignment: .leading)
                Text(tier.name).heading()
                    .foregroundStyle(here || next ? Theme.text : Theme.dim)
                if here {
                    Text("YOU ARE HERE").caption(weight: .medium).foregroundStyle(Theme.background)
                        .padding(.horizontal, 7).padding(.vertical, 3)
                        .background(Capsule().fill(Theme.accent))
                } else if next {
                    Text("NEXT").caption(weight: .medium).foregroundStyle(Theme.background)
                        .padding(.horizontal, 7).padding(.vertical, 3)
                        .background(Capsule().fill(Theme.gold))
                }
                Spacer(minLength: 6)
                if let bar = tier.promotionRating {
                    Text(Copy.moveUpAt(bar)).caption().foregroundStyle(Theme.dim.opacity(0.8))
                }
            }
            GlossaryText(tier.castLine, size: TextRole.caption.base,
                         colour: here || next ? Theme.dim : Theme.dim.opacity(0.6),
                         showing: $showing)
        }
        .padding(.vertical, 5)
        .opacity(here || next ? 1 : 0.85)
    }
}

// MARK: - The cast

/// Who is in the room, as a row of tappable words. Every one of these styles has a plain-English
/// definition behind it, which is the only reason the app is allowed to use the words at all.
struct CastRow: View {
    let tier: LadderTier
    @Binding var showing: GlossaryTerm?

    var body: some View {
        FlowRow(spacing: 6) {
            ForEach(tier.cast, id: \.style) { entry in
                HStack(spacing: 4) {
                    Text("\(entry.count)").numericSmall().foregroundStyle(Theme.gold)
                    TermChip(entry.style.name, showing: $showing, size: 10, colour: Theme.dim)
                }
                .padding(.horizontal, 8).padding(.vertical, 4)
                .background(Capsule().fill(Color.white.opacity(0.06)))
            }
        }
    }
}

/// A row of chips that wraps rather than running off the edge. Written by hand because the cast of
/// a table is between four and six chips wide and a phone gives it about half that.
#if !os(Android)
private struct FlowRow: Layout {
    var spacing: CGFloat = 6

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let width = proposal.width ?? .infinity
        let rows = laidOut(subviews, width: width)
        let height = rows.last.map { $0.y + $0.height } ?? 0
        return CGSize(width: proposal.width ?? rows.map { $0.width }.max() ?? 0, height: height)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews,
                       cache: inout ()) {
        for row in laidOut(subviews, width: bounds.width) {
            var x = bounds.minX
            for index in row.items {
                let size = subviews[index].sizeThatFits(.unspecified)
                subviews[index].place(at: CGPoint(x: x, y: bounds.minY + row.y),
                                      proposal: ProposedViewSize(size))
                x += size.width + spacing
            }
        }
    }

    private struct Row {
        var items: [Int] = []
        var y: CGFloat = 0
        var width: CGFloat = 0
        var height: CGFloat = 0
    }

    private func laidOut(_ subviews: Subviews, width: CGFloat) -> [Row] {
        var rows: [Row] = []
        var row = Row()
        var y: CGFloat = 0
        for index in subviews.indices {
            let size = subviews[index].sizeThatFits(.unspecified)
            let used = row.items.isEmpty ? size.width : row.width + spacing + size.width
            if !row.items.isEmpty, used > width {
                rows.append(row)
                y += row.height + spacing
                row = Row(items: [], y: y, width: 0, height: 0)
            }
            row.width = row.items.isEmpty ? size.width : row.width + spacing + size.width
            row.height = max(row.height, size.height)
            row.items.append(index)
        }
        if !row.items.isEmpty { rows.append(row) }
        return rows
    }
}
#else
/// Android has no `Layout` protocol to write a wrapping row against, so the cast goes into as many
/// columns as fit the width instead. Same chips, same words; they line up in rows.
struct FlowRow<Content: View>: View {
    var spacing: CGFloat = 6
    @ViewBuilder let content: Content

    var body: some View {
        LazyVGrid(columns: [GridItem(.adaptive(minimum: 110), spacing: spacing)],
                  alignment: .leading, spacing: spacing) { content }
    }
}
#endif