← poker-tutor
import SwiftUI
import PokerCore
import PokerCoach
import PokerSession
/// A saved hand, played back a step at a time.
///
/// It borrows the table's vocabulary — the same cards, the same felt, the same chip capsule — so
/// nobody has to learn a second way of reading a poker hand. It deliberately does not behave like
/// the table: it steps backwards as well as forwards, nothing is on a timer, and it never makes a
/// sound. A
/// review screen that starts dealing chips at you is a review screen you cannot think in, and a
/// noise on a screen somebody opened to read quietly is a noise they did not ask for.
///
/// All of the arithmetic — which cards are face up, what the pot stood at, which decision the
/// coach was talking about — is `HandReplay`, in the session layer, where a test can drive it
/// without a screen. This file only draws.
struct HandReplayView: View {
let record: HandRecord
/// Back to the hand's summary. The replay is a way in to one hand, not a place to get stuck.
var close: (() -> Void)?
@Environment(\.metrics) var metrics
@State var step = 0
@State var showing: GlossaryTerm?
private var replay: HandReplay? { HandReplay(record) }
var body: some View {
if let replay {
content(replay)
} else {
// Every hand played before the transcript was kept. It still has its summary; it just
// cannot be walked through, and saying so is better than drawing an empty table.
VStack(alignment: .leading, spacing: 8) {
Text(Copy.cannotReplay).heading().foregroundStyle(Theme.text)
Text(Copy.cannotReplayBlurb)
.body().foregroundStyle(Theme.dim).fixedSize(horizontal: false, vertical: true)
}
}
}
private func content(_ replay: HandReplay) -> some View {
let index = min(step, replay.lastIndex)
let moment = replay.moments[index]
return VStack(alignment: .leading, spacing: 14) {
header(replay, moment: moment, index: index)
streetStrip(replay, moment: moment)
felt(replay, moment: moment)
story(replay, moment: moment, index: index)
if let verdict = moment.verdict { coachRead(verdict) }
DefinitionSlot(showing: $showing)
controls(replay, index: index)
}
}
// MARK: - The pieces
private func header(_ replay: HandReplay, moment: HandReplay.Moment, index: Int) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 10) {
VStack(alignment: .leading, spacing: 3) {
Text("Playing hand \(record.handNumber) back").heading().foregroundStyle(Theme.text)
Text("Step \(index + 1) of \(replay.moments.count) · you were \(record.seatDescription) "
+ "of the \(replay.tableSize) dealt in")
.caption().foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
if let close {
Button(action: close) {
Text("Summary").body().foregroundStyle(Theme.dim)
.padding(.horizontal, 10).padding(.vertical, 6)
.background(Capsule().fill(Color.white.opacity(0.06)))
.contentShape(Capsule())
}
.buttonStyle(.plain)
}
}
}
/// The rounds of betting, as jumps. Only the ones the hand actually reached are offered — a
/// hand that ended before the flop has no flop to look at.
private func streetStrip(_ replay: HandReplay, moment: HandReplay.Moment) -> some View {
HStack(spacing: 6) {
ForEach(replay.streetStarts, id: \.index) { start in
let here = start.street == moment.street
Button { step = start.index } label: {
Text(start.street.name.capitalized)
.caption(weight: .medium)
.foregroundStyle(here ? Theme.background : Theme.dim)
.padding(.horizontal, 9).padding(.vertical, 5)
.background(Capsule().fill(here ? Theme.accent : Color.white.opacity(0.06)))
.contentShape(Capsule())
}
.buttonStyle(.plain)
}
Spacer()
}
}
/// The felt as it stood at this moment: the shared cards that are face up, the pot, the
/// player's own two cards, and a ring showing which seat is speaking.
private func felt(_ replay: HandReplay, moment: HandReplay.Moment) -> some View {
let boardSize: CGFloat = metrics.compact ? 34 : 46
let stack = metrics.compact
return VStack(alignment: .leading, spacing: 12) {
let cards = HStack(alignment: .center, spacing: 12) {
HStack(spacing: 5) {
ForEach(0..<5, id: \.self) { slot in
if slot < moment.board.count {
CardView(card: moment.board[slot], size: boardSize)
} else {
RoundedRectangle(cornerRadius: boardSize * 0.13, style: .continuous)
.fill(Color.black.opacity(0.16))
.frame(width: boardSize * 0.72, height: boardSize)
}
}
}
ChipsView(amount: moment.pot, caption: "\(moment.pot) in the middle")
Spacer(minLength: 0)
}
if stack {
cards
HStack(alignment: .top, spacing: 14) {
yourCards(replay)
Spacer(minLength: 0)
TableDiagram(players: replay.tableSize,
highlight: moment.position ?? record.seat, base: 108)
}
} else {
HStack(alignment: .center, spacing: 18) {
VStack(alignment: .leading, spacing: 12) {
cards
yourCards(replay)
}
Spacer(minLength: 0)
TableDiagram(players: replay.tableSize,
highlight: moment.position ?? record.seat, base: 130)
}
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(LinearGradient(colors: [Theme.feltInner.opacity(0.55), Theme.feltOuter.opacity(0.55)],
startPoint: .topLeading, endPoint: .bottomTrailing)))
.overlay(RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Theme.rail.opacity(0.7), lineWidth: 1.5))
}
private func yourCards(_ replay: HandReplay) -> some View {
HStack(alignment: .center, spacing: 10) {
HStack(spacing: 4) {
Text("You").caption(weight: .medium).foregroundStyle(Theme.hero)
ForEach(Array(record.cards.enumerated()), id: \.offset) { _, card in
CardView(card: card, size: metrics.compact ? 32 : 40)
}
}
if !replay.revealed.isEmpty {
ForEach(replay.revealed, id: \.seat) { player in
HStack(spacing: 4) {
Text(player.name).caption(weight: .medium).foregroundStyle(Theme.dim)
ForEach(Array(player.holeCards.enumerated()), id: \.offset) { _, card in
CardView(card: card, size: metrics.compact ? 26 : 32)
}
}
}
}
}
}
/// What just happened, and the run of actions leading up to it on this round of betting. The
/// list is what makes the step meaningful — one line on its own is not a hand.
private func story(_ replay: HandReplay, moment: HandReplay.Moment, index: Int) -> some View {
let sofar = replay.moments.filter { $0.kind == .acts && $0.street == moment.street && $0.id <= index }
return VStack(alignment: .leading, spacing: 10) {
GlossaryText(moment.headline, size: TextRole.body.base,
colour: moment.isHero && moment.kind == .acts ? Theme.hero : Theme.text,
showing: $showing)
if !sofar.isEmpty {
VStack(alignment: .leading, spacing: 0) {
ForEach(sofar) { line in
actionRow(line, current: line.id == index)
}
}
.background(RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.black.opacity(0.16)))
}
}
}
private func actionRow(_ moment: HandReplay.Moment, current: Bool) -> some View {
HStack(spacing: 10) {
Text(moment.actor ?? "")
.body()
.foregroundStyle(moment.isHero ? Theme.hero : Theme.text)
.frame(width: Theme.size(76, capped: true), alignment: .leading)
// The number is the part that means something; the old word rides along under it
// everywhere else, and there is no room for it in a row this narrow.
Text(moment.seatPhrase ?? "").caption(weight: .medium).foregroundStyle(Theme.dim)
.frame(width: Theme.size(74, capped: true), alignment: .leading)
Text(moment.action ?? "").body()
.foregroundStyle(current ? Theme.gold : Theme.dim)
Spacer(minLength: 0)
Text("\(moment.pot)").numericSmall().foregroundStyle(Theme.dim)
}
.padding(.horizontal, 11)
.padding(.vertical, 6)
.background(current ? Color.white.opacity(0.06) : Color.clear)
}
/// What the coach made of the player's decision at this point — including where it decided to
/// keep quiet at the table. Saying nothing mid-hand is a rule about interrupting somebody, not
/// a rule about what they are allowed to read afterwards.
private func coachRead(_ verdict: VerdictRecord) -> some View {
Group {
switch Copy.decisionNote(spoke: verdict.spoke,
countsTowardsLeak: verdict.countsTowardsLeak) {
case .flagged:
VerdictCard(record: verdict)
case .counted:
// Counted towards a habit even though the coach said nothing at the table. Saying
// "calling it a mistake would be guessing" here would deny the row this decision is
// sitting underneath on the My game screen.
CountedCard(verdict: verdict)
case .tooClose:
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text("The coach").caption(weight: .medium).foregroundStyle(Theme.dim)
Spacer()
}
Text(Copy.coachSaidNothing(about: verdict.taken))
.body().foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
}
.padding(13)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 11, style: .continuous)
.fill(Theme.panelRaised.opacity(0.6)))
}
}
}
private func controls(_ replay: HandReplay, index: Int) -> some View {
HStack(spacing: 10) {
stepButton("Back", enabled: index > 0) { step = max(0, index - 1) }
stepButton("Next", enabled: index < replay.lastIndex, filled: true) {
step = min(replay.lastIndex, index + 1)
}
Button { step = 0 } label: {
Text("Start again").body().foregroundStyle(Theme.dim)
.padding(.horizontal, 10).padding(.vertical, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Spacer(minLength: 0)
}
}
private func stepButton(_ title: String, enabled: Bool, filled: Bool = false,
action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(title).heading(capped: true)
.foregroundStyle(filled ? Theme.background : Theme.text)
.frame(minWidth: metrics.compact ? 92 : 84, minHeight: metrics.compact ? 40 : 32)
.background(RoundedRectangle(cornerRadius: 9, style: .continuous)
.fill(filled ? Theme.accent.opacity(0.9) : Color.white.opacity(0.08)))
.contentShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
.opacity(enabled ? 1 : 0.35)
}
.buttonStyle(.plain)
.disabled(!enabled)
}
}