diff --git a/App/PaywallSheet.swift b/App/PaywallSheet.swift index f42d5221..29ebd1af 100644 --- a/App/PaywallSheet.swift +++ b/App/PaywallSheet.swift @@ -6,9 +6,22 @@ import SwiftUI struct PaywallSheet: View { let store: ProStore let moment: ProOfferMoment? + /// The linked Mixer's model while Mixer Link is up, for the pack headline (#705). + let mixerModel: String? @Environment(\.dismiss) private var dismiss private var paywall: Paywall { Paywall(moment: moment, proPrice: store.proProduct?.displayPrice) } + /// The pack first with Pro beside it, Pro alone, or only the upgrade (#705). + private var items: [PackOffer.Item] { + let purchases = store.purchases + return PackOffer.items( + ownsPro: purchases.ownsPro, armedFamily: store.armedFamily(), ownedPacks: purchases.ownedPacks, + boughtPacks: purchases.boughtPacks, packsOffered: PackAvailability.isAvailableInThisBuild) + } + private var headline: String { + if case .pack(let family)? = items.first { return PackOffer.headline(family: family, mixerModel: mixerModel) } + return paywall.headline + } var body: some View { NavigationStack { @@ -16,19 +29,50 @@ struct PaywallSheet: View { Section { ForEach(paywall.features, id: \.self) { Label($0, systemImage: "checkmark") } } header: { - Text(paywall.headline).font(.title3.weight(.semibold)).textCase(nil) + Text(headline).font(.title3.weight(.semibold)).textCase(nil) } Section { Text(paywall.promise) } Section { - Button(paywall.buyTitle) { - Task { - await store.buyPro() - if store.license().tier == .pro { dismiss() } + if case .pack(let family)? = items.first { + let product = store.packProducts[family] + Button(product.map { "Buy \(family.name) pack, \($0.displayPrice)" } ?? "Buy \(family.name) pack") { + guard let product else { return } + Task { + await store.buy(product) + // Closes only when the pack came through, not on a cancel (#705). + if store.purchases.ownedPacks.contains(family) { dismiss() } + } + } + .disabled(product == nil || store.busy) + } + if items.contains(.pro) { + Button(paywall.buyTitle) { + Task { + await store.buyPro() + // On Pro owned, not the tier: with a pack on the Armed family the tier is already Pro, and a + // cancel mustn't close the sheet (#705). + if store.purchases.ownsPro { dismiss() } + } } + .disabled(store.proProduct == nil || store.busy) + } + if items.contains(.upgrade) { + let product = store.upgradeProduct + Button(product.map { "Upgrade to Pro, \($0.displayPrice)" } ?? "Upgrade to Pro") { + guard let product else { return } + Task { + await store.buy(product) + if store.purchases.ownsPro { dismiss() } + } + } + .disabled(product == nil || store.busy) + } + // With the packs, Debug only for now (#705): Release shows no price-stage line until Eric turns it on. + if PackAvailability.isAvailableInThisBuild, !items.isEmpty, let label = PriceStage.current.label { + Text(label).font(.footnote).secondaryText() } - .disabled(store.proProduct == nil || store.busy) Button("Restore Purchases") { Task { await store.restore() } } .disabled(store.busy) if let note = store.note { diff --git a/App/ProSection.swift b/App/ProSection.swift index a19f614b..1646640b 100644 --- a/App/ProSection.swift +++ b/App/ProSection.swift @@ -13,24 +13,50 @@ struct ProSection: View { @Binding var showingPaywall: Bool var body: some View { - let license = store.license() + let license = store.planLicense() let tier = license.tier + let purchases = store.purchases + // What to offer (#705): the Armed family's pack and Pro, Pro alone, or only the upgrade for a pack owner. + let items = tier == nil ? [] : PackOffer.items( + ownsPro: purchases.ownsPro, armedFamily: model.armedFamily, ownedPacks: purchases.ownedPacks, + boughtPacks: purchases.boughtPacks, packsOffered: PackAvailability.isAvailableInThisBuild) Section { - LabeledContent("Plan", value: license.planTitle) + LabeledContent("Plan", value: PackOffer.planTitle(tier: tier, ownsPro: purchases.ownsPro, ownedPacks: purchases.ownedPacks)) if let tier, tier != .pro { Button("See what Pro adds") { showingPaywall = true } } if tier == .free, !license.trialStarted { Button("Start 14-day Trial…") { showingTrial = true } } - if let tier, tier != .pro { + if case .pack(let family)? = items.first { + let product = store.packProducts[family] + Button { + if let product { Task { await store.buy(product) } } + } label: { + LabeledContent("Buy \(family.name) pack", value: priceText(product?.displayPrice)) + } + .disabled(product == nil || store.busy) + } + if items.contains(.pro) { Button { Task { await store.buyPro() } } label: { - LabeledContent("Buy Pro", value: priceText) + LabeledContent("Buy Pro", value: priceText(store.proProduct?.displayPrice)) } .disabled(store.proProduct == nil || store.busy) } + if items.contains(.upgrade) { + Button { + if let product = store.upgradeProduct { Task { await store.buy(product) } } + } label: { + LabeledContent("Upgrade to Pro", value: priceText(store.upgradeProduct?.displayPrice)) + } + .disabled(store.upgradeProduct == nil || store.busy) + } + // With the packs, Debug only for now (#705): Release shows no price-stage line until Eric turns it on. + if PackAvailability.isAvailableInThisBuild, !items.isEmpty, let label = PriceStage.current.label { + Text(label).font(.footnote).secondaryText() + } Button("Restore Purchases") { Task { await store.restore() } } .disabled(store.busy) if let note = store.note { @@ -43,13 +69,13 @@ struct ProSection: View { } } - private var priceText: String { - if let product = store.proProduct { return product.displayPrice } + private func priceText(_ price: String?) -> String { + if let price { return price } return store.loadingProducts ? "Loading…" : "Price unavailable" } private var footer: String { - switch store.license().tier { + switch store.planLicense().tier { case .pro: return "Thank you. Everything in Pro is yours." case nil: return "Your plan shows here once the App Store answers. Until then, nothing is limited." default: break diff --git a/App/ProStore.swift b/App/ProStore.swift index 58a60b83..a96b6955 100644 --- a/App/ProStore.swift +++ b/App/ProStore.swift @@ -1,4 +1,5 @@ import Foundation +import MixerLink import Recording import StoreKit @@ -17,13 +18,19 @@ final class ProStore { private(set) var status = ProStatus(lastKnown: ProStore.debugTrial ?? ProPurchases.lastKnown()) /// Whether Pro is owned and when the Trial started. var purchases: ProPurchases { status.purchases } - /// Called when the known tier changes (the App Store answered, a purchase, a refund), so the recorder can re-Arm - /// between Takes at the rate it may now use. + /// Called when the known tier or owned packs change (the App Store answered, a purchase, a refund; #705), so the + /// recorder can re-Arm between Takes at the rate it may now use. @ObservationIgnored var onTierChange: (() -> Void)? + /// The Armed input's Mixer family, set by the record screen model, so an owned pack counts on its own family (#705). + @ObservationIgnored var armedFamily: () -> MixerFamily? = { nil } /// The Pro product, once loaded. Nil until then, or when the App Store doesn't offer it. private(set) var proProduct: Product? /// The $0 14-day Trial product, once loaded. private(set) var trialProduct: Product? + /// The mixer pack products, once loaded, only where packs are offered (#705). + private(set) var packProducts: [MixerFamily: Product] = [:] + /// The upgrade to Pro for a pack owner, once loaded, only where packs are offered (#705). + private(set) var upgradeProduct: Product? private(set) var loadingProducts = true /// A purchase or restore in progress. private(set) var busy = false @@ -47,6 +54,12 @@ final class ProStore { /// What the operator may do right now. Every Pro, Free and Trial question goes through it (#439). func license(now: Date = Date()) -> License { + status.license(proOffered: Self.isOffered, armedFamily: armedFamily(), now: now) + } + + /// The plan as owned, whatever is Armed (#705): for Settings' plan line and offers, so they don't change with the + /// input and a running Trial isn't hidden by a pack on the Armed family. Record press and limits use `license()`. + func planLicense(now: Date = Date()) -> License { status.license(proOffered: Self.isOffered, now: now) } @@ -66,9 +79,13 @@ final class ProStore { func loadProducts() async { loadingProducts = true defer { loadingProducts = false } - let products = (try? await Product.products(for: [ProPurchases.proProductID, ProPurchases.trialProductID])) ?? [] + var ids = [ProPurchases.proProductID, ProPurchases.trialProductID] + if PackAvailability.isAvailableInThisBuild { ids += ProPurchases.packProductIDs.values + [ProPurchases.upgradeProductID] } + let products = (try? await Product.products(for: ids)) ?? [] proProduct = products.first { $0.id == ProPurchases.proProductID } trialProduct = products.first { $0.id == ProPurchases.trialProductID } + packProducts = ProPurchases.packProductIDs.compactMapValues { id in products.first { $0.id == id } } + upgradeProduct = products.first { $0.id == ProPurchases.upgradeProductID } } /// The Trial's disclosure, once Pro's price is known (Guideline 3.1.1 asks for it). @@ -81,6 +98,11 @@ final class ProStore { await purchase(proProduct, failure: "Couldn't buy Pro") } + /// Buys Pro, a mixer pack or the upgrade (#705). + func buy(_ product: Product) async { + await purchase(product, failure: "Couldn't buy \(product.displayName)") + } + /// Starts the 14-day Trial: a $0 purchase, so its start date is the App Store's and survives reinstalling. func startTrial() async { guard let trialProduct else { return } @@ -120,7 +142,7 @@ final class ProStore { do { try await AppStore.sync() await refreshOwned() - if !purchases.ownsPro { note = "No Pro purchase found for this Apple Account." } + note = PackOffer.restoreNote(ownsPro: purchases.ownsPro, ownedPacks: purchases.ownedPacks) } catch { note = "Couldn't restore purchases: \(error.localizedDescription)" } @@ -135,7 +157,8 @@ final class ProStore { owned.append(OwnedPurchase( productID: transaction.productID, purchaseDate: transaction.purchaseDate, - revocationDate: transaction.revocationDate)) + revocationDate: transaction.revocationDate, + isFamilyShared: transaction.ownershipType == .familyShared)) } let changed = status.appStoreAnswered(owned, now: Date()) status.purchases.saveAsLastKnown() diff --git a/App/RecordScreen.swift b/App/RecordScreen.swift index 6caa0d37..3a6316e2 100644 --- a/App/RecordScreen.swift +++ b/App/RecordScreen.swift @@ -128,7 +128,7 @@ struct RecordScreen: View { .sheet(isPresented: $showingAutoGain) { AutoGainSheet(model: model).modifier(AppearanceRoot(mode: model.appearance)) } .sheet(isPresented: $showingTrialOffer) { TrialSheet(store: model.pro).modifier(AppearanceRoot(mode: model.appearance)) } .sheet(isPresented: $showingTrialStatus) { TrialStatusSheet(store: model.pro).modifier(AppearanceRoot(mode: model.appearance)) } - .sheet(item: $shownPaywall) { request in PaywallSheet(store: model.pro, moment: request.moment).modifier(AppearanceRoot(mode: model.appearance)) } + .sheet(item: $shownPaywall) { request in PaywallSheet(store: model.pro, moment: request.moment, mixerModel: model.mixer.identity?.model).modifier(AppearanceRoot(mode: model.appearance)) } // The first-launch Trial offer: once, when the Pro price has loaded, never during a Take and never over another // sheet. A paywall that came due waits for other sheets too: presenting over Settings closed both (#357). .onChange(of: model.pro.proProduct?.displayPrice, initial: true) { _, _ in presentWaitingSheet() } @@ -1046,6 +1046,7 @@ final class RecordScreenModel { mixerGain.makeProtection = { [weak self] queue in self?.makeClipProtection(queue: queue) } mixerGain.takeAllowance = { [weak self] in self?.recorder.takeAllowance } mixerGain.allowsAutoGain = { [weak self] in self?.pro.license().allowsAutoGain ?? true } + pro.armedFamily = { [weak self] in self?.armedFamily } followTakes() sequencer.didArm = { [unowned self] device in #if DEBUG && os(iOS) diff --git a/App/SettingsView.swift b/App/SettingsView.swift index 3c077dbb..2ae90e97 100644 --- a/App/SettingsView.swift +++ b/App/SettingsView.swift @@ -157,7 +157,7 @@ struct SettingsView: View { .sheet(isPresented: $showingChannelNames) { ChannelNamesView(model: model).modifier(AppearanceRoot(mode: model.appearance)) } .sheet(isPresented: $showingSoundcheck) { SoundcheckView(model: model).modifier(AppearanceRoot(mode: model.appearance)) } .sheet(isPresented: $showingTrial) { TrialSheet(store: model.pro).modifier(AppearanceRoot(mode: model.appearance)) } - .sheet(isPresented: $showingPaywall) { PaywallSheet(store: model.pro, moment: nil).modifier(AppearanceRoot(mode: model.appearance)) } + .sheet(isPresented: $showingPaywall) { PaywallSheet(store: model.pro, moment: nil, mixerModel: model.mixer.identity?.model).modifier(AppearanceRoot(mode: model.appearance)) } .fileImporter(isPresented: $choosing, allowedContentTypes: [.folder]) { result in if case .success(let url) = result { model.drive.choose(url) } } diff --git a/App/ShowRecorder.storekit b/App/ShowRecorder.storekit index cee37357..41ba8371 100644 --- a/App/ShowRecorder.storekit +++ b/App/ShowRecorder.storekit @@ -15,7 +15,7 @@ ], "products" : [ { - "displayPrice" : "19.99", + "displayPrice" : "4.99", "familyShareable" : true, "internalID" : "6753000001", "localizations" : [ @@ -43,6 +43,51 @@ "productID" : "dev.ericdahl.ShowRecorder.trial", "referenceName" : "Trial", "type" : "NonConsumable" + }, + { + "displayPrice" : "2.99", + "familyShareable" : true, + "internalID" : "6753000003", + "localizations" : [ + { + "description" : "Everything Pro does, while an X Air mixer is the Armed input.", + "displayName" : "X Air pack", + "locale" : "en_US" + } + ], + "productID" : "dev.ericdahl.ShowRecorder.pack.xair", + "referenceName" : "X Air pack", + "type" : "NonConsumable" + }, + { + "displayPrice" : "2.99", + "familyShareable" : true, + "internalID" : "6753000004", + "localizations" : [ + { + "description" : "Everything Pro does, while an X32 mixer is the Armed input.", + "displayName" : "X32 pack", + "locale" : "en_US" + } + ], + "productID" : "dev.ericdahl.ShowRecorder.pack.x32", + "referenceName" : "X32 pack", + "type" : "NonConsumable" + }, + { + "displayPrice" : "1.99", + "familyShareable" : true, + "internalID" : "6753000005", + "localizations" : [ + { + "description" : "Pro on every input, for an owner of a mixer pack.", + "displayName" : "Upgrade to Pro", + "locale" : "en_US" + } + ], + "productID" : "dev.ericdahl.ShowRecorder.upgrade.pro", + "referenceName" : "Upgrade to Pro", + "type" : "NonConsumable" } ], "settings" : {