Lanework Pro is a subscription — the tier seam, StoreKit 2, and Settings

Phase 3 of the one-app pivot (DESIGN 12 ▸ The entitlement / Distribution,
ruled 2026-07-30; card c3a3ddd5). New Kanban/Tier/: Tier (.free/.pro —
deliberately no .lapsed case; unsubscribed and lapsed are one state) and
the pure decision Tier.resolve(from:now:) over SubscriptionFacts
(expiration + willAutoRenew), unit-tested through all five named states:
free, active, lapsed, offline-grace, never-online.

The facts are a persisted cache (standard defaults), not a live view:
StoreKit ages an expired subscription out of currentEntitlements locally,
so an offline device and a real lapse are indistinguishable from that
property alone — the cache holds the last answer, empty entitlements
read as silence, and holds end only on a definitive answer (revocation,
or the subscription-group status read Settings performs). That is 12's
offline-grace trade, resolved toward the paying user.

ProEntitlement is the local adapter (currentEntitlements +
Transaction.updates, started from launch, never from a test host);
ProStorefront holds everything networked (product load, purchase,
AppStore.sync) and only the Settings section ever constructs one — the
split is the enforcement of "never network on the open path".
beginSession reads the tier once at composition; BoardSession.tier is a
let with no path back in, so a lapse never rebinds an open session.
makeHistoryProvider now takes the tier; both tiers bind the native stack
until pro-m1 builds the git provider — the seam's consumer is named, not
invented early.

Settings gains the Pro section (subscribe with localized price, manage,
restore; a quiet unreachable line, no indefinite spinner) — the third of
the exactly-three Pro mentions; the About line gains its "…in Settings"
pointer now that there is a Settings to point at. A successful purchase
or restore offers once to reopen open boards (close + reopen through the
ordinary paths). Configuration.storekit wired into the scheme's run
action for ASC-free exercise; RELEASE.md gains the pro-m1 store-side
steps and the rule that the product must not be configured before then.

1901 tests in 319 suites green.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-30 18:14:58 -04:00
parent 2c6b8fe63a
commit 3b19883593
15 changed files with 1459 additions and 15 deletions
+176
View File
@@ -0,0 +1,176 @@
import SwiftUI
/// **The Settings Pro section** subscribe, manage, restore (12-editions.md Distribution), and
/// the third and last place the app names Lanework Pro (12 Tier naming; the other two are
/// `AboutBox`'s line and `BoardGitNote`'s contextual one).
///
/// ### 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
/// nothing, today least of all: both tiers bind the native history stack until pro-m1 builds the git
/// provider (`AppModel.makeHistoryProvider`).
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
}
}