Files
lanework/Kanban/Tier/ProSettingsSection.swift
T
rzen 99ebb69a1d The Pro surface goes dormant — no section, no signpost, machinery intact
Settings loses its Lanework Pro section and the About box its Pro line —
no surface names or sells Pro while the split is re-decided. The machinery
underneath (ProEntitlement, ProStorefront, ProSettingsSection, the
recorded session tier) stays compiled and tested, documented as dormant
since the pivot. CHANGELOG's two 2.0 entries and README's feature list
drop the Pro-gated framing while keeping the facts.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-07 20:21:35 -04:00

184 lines
8.5 KiB
Swift

import SwiftUI
/// **The Settings Pro section** — subscribe, manage, restore (12-editions.md ▸ Distribution).
///
/// **Dormant since the 2026-08-07 pivot** (12 ▸ PIVOT 2026-08-07 — git leaves the paywall): git
/// integration left the paywall and the base/Pro feature split is being re-decided, so this view is
/// no longer instantiated from `SettingsView` — it is unrendered pending the new split. The type,
/// its logic, and its tests stay exactly as they were: the entitlement's mechanics (local read,
/// composition-time binding, offline grace, the recorded session tier) are unchanged and correct
/// for whatever the next split gates, and this section is the seam that will surface it again once
/// that split is ruled. Nothing below this comment was touched for the pivot.
///
/// ### Calm, because the rule says calm
///
/// "The free tier presents as a complete app, not a demo ... Nothing on the welcome screen, nothing
/// in banners." This section is where the subscription actually lives, so it is allowed to describe
/// itself — and that is all it does: what Pro adds, in the same sentence the About box uses, then the
/// price and the buttons. No badge, no comparison table, no countdown, no second sentence selling the
/// first. A user who never subscribes should be able to read this pane and feel they are looking at a
/// feature they do not need, not an ad they have to dismiss.
///
/// ### Three states, all of them sentences
///
/// - **Subscribed** — says so, with whatever renewal or expiry date StoreKit exposes, and offers
/// Manage. Restore is not shown: there is nothing to restore onto an account that is already
/// entitled here.
/// - **Not subscribed, product loaded** — the localized price and Subscribe, with Restore Purchases
/// beside it for the account that owns a subscription this Mac has never seen.
/// - **Not subscribed, App Store unreachable** — one quiet line and Try Again, never an indefinite
/// spinner (`ProStorefront`'s own note). Restore stays available: it is the button whose whole job
/// is to reach the App Store, so hiding it in the state where the App Store could not be reached
/// would remove the one thing worth pressing.
///
/// ### The reopen offer
///
/// "Subscribe takes effect at each board's next open ... The purchase flow offers to reopen open
/// boards so the upgrade feels immediate" (12 ▸ The entitlement). The offer is raised **once**, from
/// here, on the one outcome that means an active subscription just landed — and declining costs a
/// gitless board nothing at all: the provider follows the board, so its undo is the same native
/// stack before and after (`AppModel.makeHistoryProvider`). What a Not Now defers is the git trail
/// on the boards that have a repository.
struct ProSettingsSection: View {
@Environment(AppModel.self) private var appModel
/// Built on appearance rather than at init, because the entitlement it wraps comes from the
/// environment — and dropped when the pane goes, which is what keeps the networked half of Pro
/// out of every other part of the app (`ProStorefront`'s note).
@State private var storefront: ProStorefront?
/// The reopen offer, raised at most once per activation.
@State private var isOfferingReopen = false
/// How many boards the offer was raised over. Captured when the offer is made, like the trash
/// confirmations capture their counts (`TrashConfirmations`): the user is being asked about the
/// boards that were open when they subscribed, and a count recomputed at render time could
/// disagree with the sentence they are reading.
@State private var openBoardCount = 0
var body: some View {
Section {
content
} header: {
Text("Lanework Pro")
} footer: {
Text("Lanework Pro adds git-backed board history and sync. It takes effect the next time each board opens.")
}
.task {
let storefront = storefront ?? ProStorefront(entitlement: appModel.entitlement)
self.storefront = storefront
await storefront.load()
}
.alert("Reopen your open boards?", isPresented: $isOfferingReopen) {
Button("Reopen") {
Task { await appModel.reopenOpenBoards() }
}
Button("Not Now", role: .cancel) {}
} message: {
Text(reopenMessage)
}
}
// MARK: Content
@ViewBuilder
private var content: some View {
if appModel.entitlement.tier == .pro {
subscribedRows
} else {
unsubscribedRows
}
}
/// The subscribed state. One status line and one button — the App Store owns everything else
/// about a subscription (price changes, cancellation, billing), and duplicating any of it here
/// would be a second, staler answer.
@ViewBuilder
private var subscribedRows: some View {
// One `Text`, deliberately, rather than a status word beside a date: VoiceOver reads it as
// the one sentence it is, and nothing about the state is carried by layout
// (10-accessibility.md — nothing is ever said by position or colour alone).
Text(subscriptionStatusLine)
Button("Manage Subscription…") {
storefront?.openManageSubscriptions()
}
.accessibilityHint("Opens your subscriptions in the App Store.")
}
@ViewBuilder
private var unsubscribedRows: some View {
switch storefront?.availability ?? .idle {
case .idle, .loading:
// A determinate, one-line placeholder rather than a progress spinner: the load is short,
// and a spinner that resolves into a price reads as a slower price.
Text("Checking the App Store…")
.foregroundStyle(.secondary)
case let .ready(product):
HStack {
Text(product.displayPrice)
Spacer()
Button("Subscribe") {
Task { await activate { await storefront?.subscribe() } }
}
.disabled(storefront?.isBusy ?? false)
.accessibilityLabel("Subscribe to Lanework Pro, \(product.displayPrice)")
}
case .unreachable:
Text("Can't reach the App Store right now.")
.foregroundStyle(.secondary)
Button("Try Again") {
Task { await storefront?.load() }
}
.disabled(storefront?.isBusy ?? false)
}
Button("Restore Purchases") {
Task { await activate { await storefront?.restore() } }
}
.disabled(storefront?.isBusy ?? false)
.accessibilityHint("Checks this Apple Account for a Lanework Pro subscription.")
if let message = storefront?.message {
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
// MARK: Wording
/// What a subscriber's status line says, built from exactly what StoreKit cached — and nothing
/// more. An unknown expiry says the plain fact and stops, rather than inventing a date.
private var subscriptionStatusLine: String {
let facts = appModel.entitlement.facts
guard let expiration = facts.expiration, expiration < .distantFuture else {
return "Subscribed."
}
let date = expiration.formatted(date: .abbreviated, time: .omitted)
return facts.willAutoRenew ? "Subscribed — renews \(date)." : "Subscribed — ends \(date)."
}
private var reopenMessage: String {
let boards = openBoardCount == 1 ? "the board you have open" : "the \(openBoardCount) boards you have open"
return "Pro takes effect at each board's next open. Lanework can close and reopen \(boards) now."
}
// MARK: Actions
/// Runs a purchase or a restore and raises the reopen offer on the one outcome that earns it.
///
/// The count is read here, before the alert exists, for the reason `TrashConfirmations` captures
/// its phrasing at request time. And the offer is skipped outright when nothing is open, because
/// an offer to reopen no boards is a dialog with nothing behind it.
private func activate(_ work: () async -> ProStorefront.Outcome?) async {
guard await work() == .activated else { return }
let count = appModel.storeRegistry.openBoardCount
guard count > 0 else { return }
openBoardCount = count
isOfferingReopen = true
}
}