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