The 2026-07-31 re-ruling: gitless boards bind the native stack in every tier — a Pro upgrade no longer removes undo from mode-none boards — and Pro git boards bind the git provider; repo-nested stays the no-undo case under Pro, while the free tier (which never runs detection) binds native there too, per 12's inert posture. Add-git now swaps a live native substrate mid-session: the in-flight stack is cleared with the discarded provider, the git trail seeds from the root commit, and the same BoardUndoManager instance keeps nil-target menu validation fresh. 2405 tests in 413 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
178 lines
8.0 KiB
Swift
178 lines
8.0 KiB
Swift
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 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
|
|
}
|
|
}
|