← poker-tutor
import SwiftUI
import PokerCore
import PokerCoach
import PokerSession
/// Picks which hands the screen is about: this sitting, everything, or one particular sitting.
struct ScopeBar: View {
@Environment(AppState.self) var app
@Environment(\.metrics) var metrics
var body: some View {
HStack(spacing: 10) {
Menu {
Button("This session") { app.scope = .thisSession }
Button("All time") { app.scope = .allTime }
if !app.pastSessions.isEmpty {
Divider()
ForEach(app.pastSessions.prefix(20)) { summary in
Button(HistoryFilter.title(for: summary)) { app.scope = .past(summary.id) }
}
}
} label: {
HStack(spacing: 6) {
// These two sit in the line of text, so they move with it.
Symbol("line.3.horizontal.decrease.circle", size: Theme.size(12))
Text(app.scopeName).caption(weight: .medium)
Symbol("chevron.down", size: Theme.size(9))
}
.foregroundStyle(Theme.text)
.padding(.horizontal, 12)
.frame(minHeight: metrics.compact ? 38 : 30)
.background(RoundedRectangle(cornerRadius: 9, style: .continuous).fill(Theme.panelRaised))
}
.plainMenuStyle()
.fixedSize()
Text(Copy.hands(app.scopedHands.count))
.caption().foregroundStyle(Theme.dim)
Spacer()
}
}
}
/// The headline numbers, in the vocabulary a poker player already uses.
///
/// These four names are the one place the plain-English rule cannot be kept: VPIP and the rest have
/// no plain equivalent, and renaming them would make the numbers incomparable with what a player
/// reads anywhere else. So the label is tappable instead, and the definition opens under the row.
struct AnalysisSummary: View {
let analysis: GameAnalysis
@Environment(\.metrics) var metrics
@State var showing: GlossaryTerm?
var body: some View {
VStack(alignment: .leading, spacing: 14) {
// With no hands at all these were three noughts and a "+0.0 bb", printed in the same
// confident type as a real result — and the sentence that would have explained them
// sat inside a branch that only ran once there were hands, so it could never appear
// beside them. Nought reads as "this is free", which is the one thing this screen
// refuses to imply anywhere else on it.
let empty = analysis.hands == 0
grid([
("Hands", nil, "\(analysis.hands)", Theme.text),
("Won", nil, analysis.netChipsText,
empty ? Theme.dim : (analysis.netChips >= 0 ? Theme.accent : Theme.warn)),
("Per 100 hands", "bb/100", analysis.bbPer100Text,
empty ? Theme.dim : (analysis.bbPer100 >= 0 ? Theme.accent : Theme.warn)),
// Named for the decisions it is actually summing. The habits at the top of this
// screen count the cheap decisions the coach stays quiet on as well, so their
// total is always larger — two numbers both called "what your mistakes cost", a
// few inches apart, over different sets of decisions.
("Flagged mistakes", "bb/100", analysis.costPer100Text,
empty ? Theme.dim : Theme.gold),
])
if !empty {
Divider().overlay(Color.white.opacity(0.06))
grid([
("VPIP", "VPIP", rate(analysis.vpip), Theme.text),
("PFR", "PFR", rate(analysis.pfr), Theme.text),
("To showdown", "went to showdown", rate(analysis.wentToShowdown), Theme.text),
("Won at showdown", "won at showdown", rate(analysis.wonAtShowdown), Theme.text),
])
}
GlossaryText(footnote, size: TextRole.caption.base, showing: $showing)
DefinitionSlot(showing: $showing)
}
}
/// The arranged hands are handed over as well as the dealt ones. Without them this panel read
/// "HANDS 0" and "nothing has been played" straight after a forty-hand assessment, which is the
/// first thing anybody does.
private var footnote: String {
Copy.statisticsFootnote(hands: analysis.hands,
arranged: analysis.assessmentHands + analysis.drillHands,
enough: analysis.enoughHands)
}
private func rate(_ value: GameAnalysis.Rate) -> String {
value.of == 0 ? "—" : "\(value.percent)%"
}
private func grid(_ items: [(String, String?, String, Color)]) -> some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), alignment: .leading),
count: metrics.compact ? 2 : 4), spacing: 12) {
ForEach(items, id: \.0) { title, term, value, colour in
VStack(alignment: .leading, spacing: 2) {
if let term {
TermChip(term, showing: $showing, display: title.uppercased(), size: TextRole.caption.base)
} else {
Text(title.uppercased()).caption(weight: .medium).foregroundStyle(Theme.dim)
}
Text(value).numeric(metrics.compact ? 16 : 18).foregroundStyle(colour)
}
}
}
}
}
/// Where the money actually comes from and goes to.
///
/// The seat number leads and the old name sits under it, because "UTG+1" is a fact about a table
/// the player cannot see. Picking a row draws that table and says, in words, how many people still
/// get to act after them from there — which is the thing the name was standing in for all along.
struct PositionTable: View {
let analysis: GameAnalysis
@Environment(AppState.self) var app
@State var chosen: String?
/// Ken, 2026-08-23: "on the my game screen, the text scrolls too far over to the right and cant
/// be fully read."
///
/// Every column here used to have a fixed width multiplied by the player's text-size setting.
/// Four of those add up to 270 points at Normal, and a phone has about 307 to give once the
/// screen's margins and the panel's padding are taken out — so it fitted, barely, and then
/// stopped fitting the moment somebody turned the type up. Which is exactly what somebody who
/// finds a table hard to read does.
///
/// The seat column takes whatever is left instead of a width of its own, so the row can never
/// be wider than the screen. Its text is the only thing here that can afford to wrap or shorten
/// — "Seat 2 of 9" over "Big blind" — where a number that loses its last digit is worse than
/// useless, since it still looks like a number.
///
/// The three number columns are capped: they grow with the setting up to Large and no further,
/// the same rule the seat boxes on the felt already follow, because past that the columns eat
/// the seat name whole.
/// The widths themselves live on `Metrics`, with the rest of the sizes that depend on the
/// space available — which is what lets the suite check that a row fits a phone at every text
/// size, rather than it being found by somebody reading their own stats.
@Environment(\.metrics) var metrics
private var selected: GameAnalysis.PositionStat? {
analysis.byPosition.first { $0.id == chosen } ?? analysis.byPosition.first
}
var body: some View {
VStack(spacing: 8) {
HStack(spacing: metrics.statGap) {
Text("SEAT").caption(weight: .medium).foregroundStyle(Theme.dim)
.frame(maxWidth: .infinity, alignment: .leading)
Text("HANDS").caption(weight: .medium).foregroundStyle(Theme.dim)
.frame(width: metrics.statHands, alignment: .trailing)
Text("PLAYED").caption(weight: .medium).foregroundStyle(Theme.dim)
.frame(width: metrics.statPlayed, alignment: .trailing)
Text("PER 100").caption(weight: .medium).foregroundStyle(Theme.dim)
.frame(width: metrics.statPerHundred, alignment: .trailing)
}
ForEach(analysis.byPosition) { row in
Button { chosen = row.id } label: { line(row) }
.buttonStyle(.plain)
}
if analysis.byPosition.contains(where: { $0.hands < 25 }) {
HStack {
Text(Copy.thinSeatRows)
.caption().foregroundStyle(Theme.dim.opacity(0.8))
Spacer()
}
}
if let selected {
Divider().overlay(Color.white.opacity(0.06))
SeatExplainer(players: selected.tableSize,
seat: Position(rawValue: selected.position))
// Any seat can be drilled, not only the one the diagnosis names: a player who
// knows which seat they are bad at should not have to wait for the hands to
// prove it before practising there.
if let seat = Position(rawValue: selected.position) {
HStack {
GhostButton(title: app.mayDrillSeat(seat)
? Copy.drillSeatTitle(seat.seatPhrase(inTableOf: selected.tableSize))
: Copy.seatDrillLocked,
wide: metrics.compact) {
app.startOrResumeSeatDrill(seat) // shows the price where locked
}
if !metrics.compact { Spacer() }
}
}
}
}
}
private func line(_ row: GameAnalysis.PositionStat) -> some View {
HStack(spacing: metrics.statGap) {
VStack(alignment: .leading, spacing: 1) {
Text(row.seatNumber.map { "Seat \($0) of \(row.tableSize)" } ?? row.position)
.numericSmall().foregroundStyle(Theme.text)
Text(row.fullName).caption(weight: .medium).foregroundStyle(Theme.dim)
}
.frame(maxWidth: .infinity, alignment: .leading)
Text("\(row.hands)").numericSmall(weight: .regular).foregroundStyle(Theme.dim)
.frame(width: metrics.statHands, alignment: .trailing)
Text(row.vpip.of == 0 ? "—" : "\(row.vpip.percent)%")
.numericSmall(weight: .regular).foregroundStyle(Theme.dim)
.frame(width: metrics.statPlayed, alignment: .trailing)
Text(String(format: "%+.1f bb", row.bbPer100))
.numericSmall()
.foregroundStyle(row.hands < 25 ? Theme.dim : (row.bbPer100 >= 0 ? Theme.accent : Theme.warn))
.frame(width: metrics.statPerHundred, alignment: .trailing)
}
.padding(.vertical, 4)
.padding(.horizontal, 6)
.background(RoundedRectangle(cornerRadius: 7, style: .continuous)
.fill(selected?.id == row.id ? Theme.panelRaised : Color.clear))
.contentShape(Rectangle())
}
}
/// Which decisions cost the money, and on which street.
///
/// The rows are the scorecard's, and that is the point of them: the panel above this one and this
/// one used to count the same mistakes separately, under different names, a few inches apart. One
/// habit, one name, one number. A cost the coach could not work out says so rather than showing a
/// nought, which would read as free.
struct CostBreakdown: View {
let scorecard: LeakScorecard
let analysis: GameAnalysis
@State var showing: GlossaryTerm?
/// Every habit that has actually gone wrong in these hands, dearest first. Priced ones lead;
/// an unpriced one sits below them rather than being given a nought and ranked as harmless.
private var rows: [LeakScorecard.Entry] {
scorecard.allEntries.filter { $0.times > 0 }.sorted { a, b in
switch (a.costInBB, b.costInBB) {
case let (x?, y?): return x != y ? x > y : a.times > b.times
case (.some, .none): return true
case (.none, .some): return false
case (.none, .none): return a.times > b.times
}
}
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
if rows.isEmpty {
Text(Copy.noFlaggedHands)
.body().foregroundStyle(Theme.dim).fixedSize(horizontal: false, vertical: true)
} else {
VStack(spacing: 9) {
ForEach(rows.prefix(6)) { entry in
HStack(spacing: 10) {
Text(entry.leak.name).body().foregroundStyle(Theme.text)
.frame(maxWidth: .infinity, alignment: .leading)
Text("\(entry.times)×").numericSmall().foregroundStyle(Theme.dim)
.frame(width: Theme.size(40), alignment: .trailing)
Text(entry.costInBB.map { String(format: "%.1f bb", $0) } ?? "not known")
.numericSmall()
.foregroundStyle(entry.costIsKnown ? Theme.warn : Theme.dim)
.frame(width: Theme.size(68), alignment: .trailing)
}
}
}
if !analysis.byStreet.isEmpty {
Divider().overlay(Color.white.opacity(0.06))
HStack(spacing: 18) {
ForEach(analysis.byStreet) { street in
VStack(alignment: .leading, spacing: 2) {
// "Preflop" and "the turn" are rounds of betting, not English —
// which is why the round is the tappable part of the tile.
TermChip(street.street, showing: $showing,
display: street.street.uppercased(), size: 9)
Text(String(format: "%.1f bb", street.costBB))
.numeric().foregroundStyle(Theme.gold)
}
}
Spacer()
}
DefinitionSlot(showing: $showing)
}
// The two halves of this panel count different hands, and they have to: how often
// a habit happens is measured over assessment hands and what it cost is not.
//
// This used to sit inside the branch above, which draws nothing at all when every
// hand in view was arranged — so the explanation appeared in every case where it
// was barely needed and was missing from the one case it exists for, where the
// rows above are made entirely of arranged hands.
if analysis.assessmentHands > 0 {
Text(Copy.roundsLeaveOutArranged(analysis.assessmentHands))
.caption().foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
}
/// The two hands worth looking at again.
struct NotableHands: View {
let analysis: GameAnalysis
let open: (HandRecord) -> Void
var body: some View {
VStack(spacing: 10) {
if let best = analysis.best, best.netChips > 0 { row("Biggest win", best, Theme.accent) }
if let worst = analysis.worst, worst.netChips < 0 { row("Worst hand", worst, Theme.warn) }
}
}
private func row(_ title: String, _ hand: HandRecord, _ colour: Color) -> some View {
Button { open(hand) } label: {
HStack(spacing: 10) {
Text(title).body().foregroundStyle(Theme.dim)
.frame(width: Theme.size(96), alignment: .leading)
HStack(spacing: 3) {
ForEach(Array(hand.cards.enumerated()), id: \.offset) { _, card in
CardView(card: card, size: 26)
}
}
Text(hand.seatPhrase).caption(weight: .medium).foregroundStyle(Theme.dim)
Spacer()
Text(hand.netChips >= 0 ? "+\(hand.netChips)" : "\(hand.netChips)")
.numeric().foregroundStyle(colour)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
/// Which seat the player loses most from, and why — and the drill that sits them there.
///
/// Ken, 2026-09-11: "a mode that diagnoses which position they play the worst in and why and let
/// them drill while playing only that position." The diagnosis is shown to everyone: the by-seat
/// table it is read from already is. The drill is gated like every other drill.
struct WhereYouLoseMost: View {
@Environment(AppState.self) var app
@Environment(\.metrics) var metrics
@State var showing: GlossaryTerm?
var body: some View {
let diagnosis = app.seatDiagnosis
Panel(title: Copy.whereYouLoseMost, subtitle: Copy.whereYouLoseMostSubtitle) {
if let worst = diagnosis.worst, let headline = diagnosis.headline, let seat = worst.seat {
GlossaryText(headline, size: TextRole.body.base, colour: Theme.text, showing: $showing)
if let evidence = diagnosis.evidenceLine {
Text(evidence).caption().foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
}
if !worst.leading.isEmpty {
VStack(alignment: .leading, spacing: 4) {
ForEach(worst.leading, id: \.leak.id) { item in
HStack(alignment: .firstTextBaseline, spacing: 6) {
Symbol("target", size: Theme.size(10, capped: true)).foregroundStyle(Theme.accent)
Text("\(item.leak.name) — \(item.times) \(item.times == 1 ? "time" : "times")")
.caption(weight: .medium).foregroundStyle(Theme.accent.opacity(0.9))
}
}
}
}
if let standing = app.drills.standing(ofSeat: seat) {
Text(standing.badge(now: app.drills.now)).caption(weight: .medium).foregroundStyle(Theme.gold)
}
DefinitionSlot(showing: $showing)
HStack(spacing: 10) {
PillButton(title: app.mayDrillSeat(seat) ? Copy.seatDrillTitle : Copy.seatDrillLocked,
wide: metrics.compact) {
app.startOrResumeSeatDrill(seat)
}
if !metrics.compact { Spacer() }
}
} else {
// The honest answer, with the number that would change it.
Text(diagnosis.reason ?? "").body().foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}