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 } }