← poker-tutor
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