Advertisement

← poker-tutor

Sources/PokerUI/SettingsView.swift

15.2 KB Download file
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)
            }
        }
    }
}