← poker-tutor
import SwiftUI
import PokerCore
import PokerSession
/// The whole app, laid out for whatever space it has been given.
///
/// There is one rule here and the rest follows from it: a phone is not a small Mac. On a wide
/// screen the screens sit beside a sidebar; on a phone they sit above a tab bar, the seats shrink,
/// and the buttons grow — a decision made with a thumb needs a bigger target than one made with a
/// pointer, not a smaller one.
public struct RootView: View {
@Environment(AppState.self) var app
public init() {}
public var body: some View {
@Bindable var app = app
GeometryReader { geo in
let metrics = Metrics(width: geo.size.width, height: geo.size.height)
Group {
if metrics.compact {
VStack(spacing: 0) {
screen
TabBar()
}
} else {
HStack(spacing: 0) {
Sidebar()
Divider().overlay(Color.white.opacity(0.06))
screen
}
}
}
.environment(\.metrics, metrics)
.background(Theme.background)
// Colours and type sizes are both read statically as the screen draws, so nothing is
// watching them. Changing either identity throws this whole subtree away and builds it
// again, which is the redraw.
.id("\(app.palette.rawValue)-\(app.textScale.rawValue)-\(app.cardColours.rawValue)")
// One presenter for the lot. A view has a single sheet, so six of these stacked up
// meant handing one over to the next was a dismissal racing a presentation — and the
// one that lost was the drill report, which is the screen a drill must never skip.
.sheet(item: $app.sheet, onDismiss: { app.sheetDismissed() }) { sheet in
Group {
switch sheet {
case .tutorial:
Tutorial().interactiveDismissDisabled()
case .review:
if let review = app.review { ReviewSheet(review: review) }
case .assessmentBrief:
AssessmentBrief()
case .assessmentReport:
if let report = app.assessmentReport {
AssessmentReportView(report: report)
}
case .drillBrief(let target, let retest):
DrillBrief(target: target, retest: retest)
case .drillReport(let run):
DrillReportView(run: run)
case .paywall(let dismissable):
// Swiping it away when the hands are gone would land on a table that
// cannot deal. The only ways out are paying or the Leave button behind it.
PaywallView(dismissable: dismissable) { app.sheet = nil }
.interactiveDismissDisabled(!dismissable)
}
}
.environment(app)
.environment(\.metrics, metrics)
}
}
.background(Theme.background)
}
@ViewBuilder private var screen: some View {
Group {
switch app.screen {
case .home: HomeView()
case .table: TableView()
case .practice: PracticeView()
case .leaks: LeaksView()
case .history: HistoryView()
case .ladder: LadderView()
case .settings: SettingsView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
/// The wide layout: every screen listed down the side, with the rating parked at the bottom.
struct Sidebar: View {
@Environment(AppState.self) var app
var body: some View {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 8) {
ZStack {
RoundedRectangle(cornerRadius: 7, style: .continuous).fill(AdLabMark.olive)
.frame(width: 26, height: 26)
// Fixed: the spade is a mark inside a 26-point tile, not something to read.
Text("♠").font(.system(size: 15, weight: .bold)).foregroundStyle(Theme.background)
}
VStack(alignment: .leading, spacing: 1) {
Text(app.variant.displayName).heading(capped: true)
.foregroundStyle(Theme.text)
AdLabMark(size: 9, showRule: false)
}
}
.padding(.bottom, 18)
.padding(.horizontal, 6)
ForEach(Screen.allCases) { screen in
let disabled = screen == .table && !app.isPlaying
Button {
if !disabled { app.screen = screen }
} label: {
HStack(spacing: 9) {
ScreenIcon(screen: screen, size: 12).frame(width: 16)
// Capped: the sidebar is a fixed 190 points wide.
Text(screen.rawValue).label(capped: true)
Spacer()
}
.foregroundStyle(app.screen == screen ? Theme.background : (disabled ? Theme.dim.opacity(0.45) : Theme.dim))
.padding(.horizontal, 10).padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(app.screen == screen ? Theme.accent : Color.clear)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(disabled)
}
Spacer()
VStack(alignment: .leading, spacing: 5) {
// Named apart from the ladder's. That one is counted over the hands at the table
// the player is on now and this one over everything ever played, they are both on
// screen at the same time on a wide window, and they disagree.
Text("RATING · ALL TIME").label(capped: true)
.foregroundStyle(Theme.dim.opacity(0.7))
// A dash rather than a number until there is a hand behind it. Same refusal as the
// rating card on Home: an empty history clamps to the middle of the scale, and the
// middle of a scale printed as a verdict is a verdict about nobody.
HStack(alignment: .lastTextBaseline, spacing: 5) {
let known = app.rating.handsBehindIt > 0
Text(known ? "\(app.rating.value)" : "—")
.numeric(22, capped: true).foregroundStyle(known ? Theme.accent : Theme.dim)
if known {
Text(app.rating.label).caption(capped: true).foregroundStyle(Theme.dim)
}
}
// The hands the rating was actually built on, which is neither the hands this
// sitting watched — the sidebar read "0 hands" every launch next to a rating built
// from a year of them — nor every line in the file. The rating drops drill and
// check hands, so counting them here put "250 hands" under a number that had seen
// fifty, which is a claim four times firmer than the evidence for it.
Text(Copy.hands(app.rating.handsBehindIt)).caption(capped: true)
.foregroundStyle(Theme.dim.opacity(0.8))
}
.padding(.horizontal, 8)
}
.padding(16)
.frame(width: Theme.size(190))
.background(Theme.panel)
}
}
/// The phone layout: the same screens, along the bottom where a thumb can reach them.
struct TabBar: View {
@Environment(AppState.self) var app
var body: some View {
HStack(spacing: 0) {
ForEach(Screen.allCases) { screen in
let disabled = screen == .table && !app.isPlaying
let selected = app.screen == screen
Button {
if !disabled { app.screen = screen }
} label: {
VStack(spacing: 3) {
ScreenIcon(screen: screen, size: 17)
// Capped: six of these share the width of a phone.
Text(screen.shortName).label(capped: true)
}
.foregroundStyle(selected ? Theme.accent : (disabled ? Theme.dim.opacity(0.4) : Theme.dim))
.frame(maxWidth: .infinity)
.padding(.top, 9)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(disabled)
}
}
.padding(.bottom, 4)
.background(
Theme.panel
.overlay(alignment: .top) { Rectangle().fill(Color.white.opacity(0.06)).frame(height: 1) }
.ignoresSafeArea(edges: .bottom)
)
}
}
/// A screen's icon, on every platform — see `Symbol`.
struct ScreenIcon: View {
let screen: Screen
let size: CGFloat
var body: some View { Symbol(screen.icon, size: size) }
}