← poker-tutor
import SwiftUI
import PokerCore
import PokerSession
struct SettingsView: View {
@Environment(AppState.self) var app
@Environment(\.metrics) var metrics
@State var confirmingReset = false
@State var showingGlossary = false
@State var showingBasics = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Settings")
.font(.system(size: metrics.titleSize, weight: .bold, design: .rounded))
.foregroundStyle(Theme.text)
Panel(title: "Colours", subtitle: Copy.coloursSubtitle) {
VStack(spacing: 10) {
ForEach(Palette.allCases) { palette in
Button { app.use(palette) } label: {
HStack(spacing: 12) {
Symbol(app.palette == palette ? "largecircle.fill.circle" : "circle", size: 15)
.foregroundStyle(app.palette == palette ? Theme.accent : Theme.dim)
VStack(alignment: .leading, spacing: 2) {
Text(palette.name).body().foregroundStyle(Theme.text)
Text(palette.detail).caption().foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
swatches(for: palette)
}
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
}
Panel(title: Copy.cardColoursTitle, subtitle: Copy.cardColoursSubtitle) {
VStack(alignment: .leading, spacing: 10) {
ForEach(CardColours.allCases) { option in
Button { app.use(option) } label: {
HStack(spacing: 12) {
Symbol(app.cardColours == option ? "largecircle.fill.circle" : "circle", size: 15)
.foregroundStyle(app.cardColours == option
? Theme.accent : Theme.dim)
VStack(alignment: .leading, spacing: 2) {
Text(option.name).body().foregroundStyle(Theme.text)
Text(option.detail).caption().foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
// The four suits in the scheme being offered, so it can be
// seen rather than imagined.
HStack(spacing: 5) {
ForEach(Suit.allCases, id: \.self) { suit in
Text(suit.symbol)
.font(.system(size: Theme.size(15), weight: .semibold))
.foregroundStyle(app.palette.suitColour(
suit, fourColour: option == .four))
}
}
.padding(.horizontal, 7).padding(.vertical, 3)
.background(RoundedRectangle(cornerRadius: 5, style: .continuous)
.fill(Color(red: 0.976, green: 0.973, blue: 0.961)))
}
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
Text(Copy.touchACard)
.caption().foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
}
}
Panel(title: "Text size", subtitle: Copy.textSizeSubtitle) {
VStack(spacing: 10) {
ForEach(TextScale.allCases) { scale in
Button { app.use(scale) } label: {
HStack(spacing: 12) {
Symbol(app.textScale == scale ? "largecircle.fill.circle" : "circle", size: 15)
.foregroundStyle(app.textScale == scale ? Theme.accent : Theme.dim)
VStack(alignment: .leading, spacing: 2) {
Text(scale.name).body().foregroundStyle(Theme.text)
Text(scale.detail).caption().foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
sample(at: scale)
}
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
}
Panel(title: Copy.practiceTitle, subtitle: Copy.practicePanelSubtitle) {
VStack(alignment: .leading, spacing: 10) {
Text(Copy.practiceBlurb)
.body(Theme.coachBase(TextRole.body.base)).foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
HStack {
GhostButton(title: Copy.practiceOpen, wide: metrics.compact) {
app.screen = .practice
}
if !metrics.compact { Spacer() }
}
}
}
Panel(title: Copy.basicsTitle, subtitle: Copy.basicsPanelSubtitle) {
VStack(alignment: .leading, spacing: 10) {
Text(Copy.basicsBlurb)
.body(Theme.coachBase(TextRole.body.base)).foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
HStack {
GhostButton(title: Copy.basicsOpen, wide: metrics.compact) {
showingBasics = true
}
if !metrics.compact { Spacer() }
}
}
}
// Always here, whatever somebody's standing is.
//
// Two reasons. Somebody who wants to pay before their free hands run out should not
// have to wait to be asked. And Apple's reviewer has to be able to reach the
// subscription to test it — a paywall that only appears after the free hands is a
// purchase they cannot exercise, which comes back as a rejection.
if app.subscription != nil {
Panel(title: subscriptionTitle, subtitle: subscriptionSubtitle) {
HStack {
if !app.isSubscribed {
GhostButton(title: "See the price", wide: metrics.compact) {
app.showPaywall()
}
if !metrics.compact { Spacer() }
}
}
}
}
Panel(title: "Sound", subtitle: Copy.soundSubtitle) {
Toggle(isOn: Binding(get: { app.soundOn }, set: { _ in app.toggleSound() })) {
Text(Copy.playTableSounds).body().foregroundStyle(Theme.text)
}
.toggleStyle(.switch)
.tint(Theme.accent)
}
Panel(title: Copy.pokerWordsTitle,
subtitle: Copy.pokerWordsSubtitle(Glossary.terms.count)) {
VStack(alignment: .leading, spacing: 10) {
Text(Copy.pokerWordsBlurb)
.body().foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
HStack {
GhostButton(title: "Open the list", wide: metrics.compact) {
showingGlossary = true
}
if !metrics.compact { Spacer() }
}
}
}
Panel(title: Copy.gettingStarted, subtitle: Copy.gettingStartedSubtitle) {
HStack {
GhostButton(title: "Show it again", wide: metrics.compact) {
app.showTutorial()
}
if !metrics.compact { Spacer() }
}
}
Panel(title: Copy.startOver, subtitle: Copy.startOverSubtitle) {
VStack(alignment: .leading, spacing: 10) {
Text(app.history.hands.isEmpty
? Copy.nothingSavedYet
: Copy.handsAcrossSittings(hands: app.history.hands.count,
sittings: app.history.sessions().count))
.body().foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
HStack {
GhostButton(title: "Reset history", tint: Theme.warn,
wide: metrics.compact) { confirmingReset = true }
if !metrics.compact { Spacer() }
}
.disabled(app.history.hands.isEmpty)
.opacity(app.history.hands.isEmpty ? 0.45 : 1)
}
}
Panel(title: Copy.whereHandsAreKept, subtitle: Copy.whereHandsAreKeptSubtitle) {
Text(Copy.whereHandsAreKeptBlurb)
.body().foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
}
Panel(title: Copy.madeByTitle, subtitle: Copy.madeBySubtitle) {
VStack(alignment: .leading, spacing: 12) {
Text(Copy.madeByBlurb)
.body(Theme.coachBase(TextRole.body.base)).foregroundStyle(Theme.dim)
.fixedSize(horizontal: false, vertical: true)
// Links rather than buttons: on both platforms this hands off to the
// browser, and the app has no business having its own.
let links = Group {
Link(Copy.visitAdLab, destination: URL(string: Copy.adLabURL)!)
Link(Copy.readAboutPokerTutor,
destination: URL(string: Copy.pokerTutorPageURL)!)
Link(Copy.privacyLink, destination: URL(string: Copy.privacyURL)!)
}
.font(.system(size: Theme.size(Theme.coachBase(12)),
weight: .regular, design: .rounded))
.foregroundStyle(Theme.accent)
if metrics.compact {
VStack(alignment: .leading, spacing: 10) { links }
} else {
HStack(spacing: 18) { links; Spacer() }
}
Text(Copy.versionLine(AppInfo.version))
.caption(weight: .medium).foregroundStyle(Theme.dim.opacity(0.8))
}
}
}
.padding(metrics.gutter)
}
.background(Theme.background)
.sheet(isPresented: $showingBasics) {
BasicsView().environment(\.metrics, metrics)
}
.sheet(isPresented: $showingGlossary) {
GlossaryView().environment(\.metrics, metrics)
}
.confirmationDialog(Copy.resetTitle, isPresented: $confirmingReset, titleVisibility: .visible) {
Button("Delete \(app.history.hands.count) hands", role: .destructive) { app.resetHistory() }
Button("Keep them", role: .cancel) {}
} message: {
Text(Copy.resetWarning)
}
}
/// A line of the coach's own writing, set at the size being offered, so the choice is made by
/// reading it rather than by guessing what "Large" means. The size is asked for directly rather
/// than through `Theme`, which is still showing whichever one is switched on.
///
/// The fixed 120 points was reported as truncating the Extra large sample, and it was measured
/// rather than taken on trust: "Raise here" in the rounded system face comes to 53.3 points at
/// 11, 61.4 at 13, 69.4 at 15 and 81.7 at 18 — and 18 is what Extra large makes of a 13-point
/// line. It has forty points of room at the largest setting the app offers, so it is left
/// alone. Anything longer than this would want `fixedSize` here.
/// What the subscription panel calls itself. A subscriber is told they are one; everybody else
/// is told what they have left rather than being sold at.
private var subscriptionTitle: String {
app.isSubscribed ? Copy.subscribedTitle : "Free hands"
}
private var subscriptionSubtitle: String {
app.isSubscribed ? Copy.subscribedSubtitle : Copy.handsLeftWarning(app.handsLeft)
}
private func sample(at scale: TextScale) -> some View {
Text("Raise here")
.font(.system(size: scale.size(13), weight: .regular, design: .rounded))
.foregroundStyle(Theme.text)
.lineLimit(1)
.frame(width: 120, alignment: .trailing)
}
/// The colours that actually carry meaning, so the choice can be judged by eye. Asked of the
/// scheme directly: switching the whole app over to it mid-draw and back again is not a way to
/// read four colours.
private func swatches(for palette: Palette) -> some View {
HStack(spacing: 4) {
ForEach(Array(palette.colours().enumerated()), id: \.offset) { _, colour in
Circle().fill(colour).frame(width: 14, height: 14)
}
}
}
}