← poker-tutor
import SwiftUI
import PokerCoach
/// The basics, as a short course somebody can read in ten minutes.
///
/// Deliberately not part of the first-run walk-through. That one exists to get somebody to the
/// table without being lost; this exists for the moment a few hands in when they realise nobody
/// ever told them how big a bet is supposed to be. Putting the two together would mean either
/// burying this where nobody finds it, or making the way in ten pages long.
///
/// One lesson at a time, with the answer first. Somebody looking up "how much do I raise" wants the
/// number, not three paragraphs and then the number.
struct BasicsView: View {
@Environment(AppState.self) var app
@Environment(\.metrics) var metrics
@Environment(\.dismiss) var dismiss
@State var open: String?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider().overlay(Color.white.opacity(0.07))
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
ForEach(Basics.lessons) { lesson in
LessonRow(lesson: lesson,
open: open == lesson.id,
toggle: { toggle(lesson.id) },
practise: practise(lesson))
}
Text(Copy.basicsFooter)
.body(Theme.coachBase(TextRole.caption.base)).foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 6)
}
.padding(metrics.gutter)
}
}
.frame(minWidth: metrics.compact ? nil : Theme.size(620),
minHeight: metrics.compact ? nil : Theme.size(520))
.background(Theme.background)
}
/// Reading it is half of it. The other half is being put in the spot until the answer stops
/// needing to be worked out — which the drills already do, so a lesson borrows the one that
/// deals its situation rather than growing a second machine that does the same thing.
private func practise(_ lesson: BasicLesson) -> (() -> Void)? {
guard let id = lesson.practises,
let leak = LeakCatalogue.all.first(where: { $0.id == id }) else { return nil }
return {
dismiss()
app.offerDrill(leak)
}
}
private func toggle(_ id: String) {
withAnimation(.easeOut(duration: 0.16)) { open = open == id ? nil : id }
}
private var header: some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(Copy.basicsTitle)
.font(.system(size: metrics.titleSize, weight: .bold, design: .rounded))
.foregroundStyle(Theme.text)
Text(Copy.basicsSubtitle(Basics.lessons.count))
.body(Theme.coachBase(TextRole.body.base)).foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
// Every sheet in the app needs its own way out. A sheet opened from a button and
// closable only by a gesture somebody has to already know is a dead end on a Mac.
GhostButton(title: "Done") { dismiss() }
}
.padding(metrics.gutter)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Theme.panel)
}
/// A question, its one-line answer, and the reasoning underneath once it is asked for.
///
/// The short answer is always visible. Somebody who only wants the number gets it without
/// opening anything, and somebody who wants to know why it is that number can have the rest.
struct LessonRow: View {
@Environment(\.metrics) var metrics
let lesson: BasicLesson
let open: Bool
let toggle: () -> Void
let practise: (() -> Void)?
@State var showing: GlossaryTerm?
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Button(action: toggle) {
VStack(alignment: .leading, spacing: 5) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(lesson.question)
.heading(Theme.coachBase(TextRole.heading.base)).foregroundStyle(Theme.text)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
Symbol(open ? "chevron.up" : "chevron.down", size: 11)
.foregroundStyle(Theme.dim)
}
GlossaryText(lesson.short, size: TextRole.body.base, colour: Theme.accent, showing: $showing)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if open {
VStack(alignment: .leading, spacing: 10) {
// The one-sentence answer is already in the header above, so it is not
// repeated here.
LessonBody(lesson: lesson, showing: $showing, showShort: false)
if let practise {
HStack {
GhostButton(title: Copy.practiseThis, wide: metrics.compact,
action: practise)
if !metrics.compact { Spacer() }
}
}
}
.transition(.opacity)
}
DefinitionSlot(showing: $showing)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 11, style: .continuous).fill(Theme.panel)
)
}
}
}
/// The inside of a lesson: the one-sentence answer, the reasoning, and the arithmetic where a rule
/// of thumb needs it to mean anything.
///
/// Shared between the basics screen, where somebody goes looking for it, and the coach's review,
/// where it arrives because they were told a decision was wrong and wanted to know why. Two
/// renderings of the same lesson would drift, and the review's whole job here is to say the same
/// thing the basics screen says rather than a shortened version of it.
struct LessonBody: View {
let lesson: BasicLesson
@Binding var showing: GlossaryTerm?
/// False where the caller has already shown the one-line answer in a header of its own.
var showShort = true
var body: some View {
VStack(alignment: .leading, spacing: 10) {
if showShort {
GlossaryText(lesson.short, size: TextRole.body.base, colour: Theme.accent, showing: $showing)
}
ForEach(Array(lesson.paragraphs.enumerated()), id: \.offset) { _, para in
GlossaryText(para, size: TextRole.body.base, showing: $showing)
}
if let worked = lesson.worked {
VStack(alignment: .leading, spacing: 6) {
Text(Copy.workedThrough.uppercased())
.caption(weight: .medium).foregroundStyle(Theme.gold.opacity(0.9))
GlossaryText(worked, size: TextRole.body.base, colour: Theme.text.opacity(0.9),
showing: $showing)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 9, style: .continuous).fill(Theme.panelRaised)
)
}
}
.transition(.opacity)
}
}