Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 50 additions & 6 deletions App/PaywallSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,73 @@ 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 {
Form {
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 {
Expand Down
40 changes: 33 additions & 7 deletions App/ProSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
33 changes: 28 additions & 5 deletions App/ProStore.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import MixerLink
import Recording
import StoreKit

Expand All @@ -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
Expand All @@ -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)
}

Expand All @@ -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).
Expand All @@ -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 }
Expand Down Expand Up @@ -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)"
}
Expand All @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion App/RecordScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion App/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
Expand Down
47 changes: 46 additions & 1 deletion App/ShowRecorder.storekit
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
],
"products" : [
{
"displayPrice" : "19.99",
"displayPrice" : "4.99",
"familyShareable" : true,
"internalID" : "6753000001",
"localizations" : [
Expand Down Expand Up @@ -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" : {
Expand Down
Loading