547 lines
20 KiB
Swift
547 lines
20 KiB
Swift
import SwiftUI
|
|
import StoreKit
|
|
|
|
@MainActor
|
|
final class CableProPaywallViewModel: ObservableObject {
|
|
enum LoadingState: Equatable {
|
|
case idle
|
|
case loading
|
|
case loaded
|
|
case failed(String)
|
|
}
|
|
|
|
@Published private(set) var products: [Product] = []
|
|
@Published private(set) var state: LoadingState = .idle
|
|
@Published private(set) var purchasingProductID: String?
|
|
@Published private(set) var isRestoring = false
|
|
@Published private(set) var purchasedProductIDs: Set<String> = []
|
|
@Published var alert: PaywallAlert?
|
|
|
|
private let productIdentifiers: [String]
|
|
|
|
init(productIdentifiers: [String]) {
|
|
self.productIdentifiers = productIdentifiers
|
|
Task {
|
|
await updateCurrentEntitlements()
|
|
}
|
|
}
|
|
|
|
func loadProducts(force: Bool = false) async {
|
|
if state == .loading { return }
|
|
if !force, case .loaded = state { return }
|
|
|
|
guard !productIdentifiers.isEmpty else {
|
|
products = []
|
|
state = .loaded
|
|
return
|
|
}
|
|
|
|
state = .loading
|
|
do {
|
|
let fetched = try await Product.products(for: productIdentifiers)
|
|
products = fetched.sorted { productSortKey(lhs: $0, rhs: $1) }
|
|
state = .loaded
|
|
await updateCurrentEntitlements()
|
|
} catch {
|
|
state = .failed(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
private func productSortKey(lhs: Product, rhs: Product) -> Bool {
|
|
sortIndex(for: lhs) < sortIndex(for: rhs)
|
|
}
|
|
|
|
private func sortIndex(for product: Product) -> Int {
|
|
guard let period = product.subscription?.subscriptionPeriod else { return Int.max }
|
|
switch period.unit {
|
|
case .day: return 0
|
|
case .week: return 1
|
|
case .month: return 2
|
|
case .year: return 3
|
|
@unknown default: return 10
|
|
}
|
|
}
|
|
|
|
func purchase(_ product: Product) async {
|
|
guard purchasingProductID == nil else { return }
|
|
|
|
purchasingProductID = product.id
|
|
defer { purchasingProductID = nil }
|
|
|
|
do {
|
|
let result = try await product.purchase()
|
|
switch result {
|
|
case .success(let verification):
|
|
let transaction = try verify(verification)
|
|
purchasedProductIDs.insert(transaction.productID)
|
|
alert = PaywallAlert(kind: .success, message: localizedString("cable.pro.alert.success.body", defaultValue: "Thanks for supporting Cable PRO!"))
|
|
await transaction.finish()
|
|
await updateCurrentEntitlements()
|
|
case .userCancelled:
|
|
break
|
|
case .pending:
|
|
alert = PaywallAlert(kind: .pending, message: localizedString("cable.pro.alert.pending.body", defaultValue: "Your purchase is awaiting approval."))
|
|
@unknown default:
|
|
alert = PaywallAlert(kind: .error, message: localizedString("cable.pro.alert.error.generic", defaultValue: "Something went wrong. Please try again."))
|
|
}
|
|
} catch {
|
|
alert = PaywallAlert(kind: .error, message: error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
func restorePurchases() async {
|
|
guard !isRestoring else { return }
|
|
isRestoring = true
|
|
defer { isRestoring = false }
|
|
|
|
do {
|
|
try await AppStore.sync()
|
|
await updateCurrentEntitlements()
|
|
alert = PaywallAlert(kind: .restored, message: localizedString("cable.pro.alert.restored.body", defaultValue: "Your purchases are available again."))
|
|
} catch {
|
|
alert = PaywallAlert(kind: .error, message: error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
private func verify<T>(_ result: VerificationResult<T>) throws -> T {
|
|
switch result {
|
|
case .verified(let signed):
|
|
return signed
|
|
case .unverified(_, let error):
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private func updateCurrentEntitlements() async {
|
|
var unlocked: Set<String> = []
|
|
|
|
for await result in Transaction.currentEntitlements {
|
|
switch result {
|
|
case .verified(let transaction):
|
|
if productIdentifiers.contains(transaction.productID) {
|
|
unlocked.insert(transaction.productID)
|
|
}
|
|
case .unverified:
|
|
continue
|
|
}
|
|
}
|
|
|
|
purchasedProductIDs = unlocked
|
|
}
|
|
}
|
|
|
|
struct CableProPaywallView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Binding var isPresented: Bool
|
|
@EnvironmentObject private var unitSettings: UnitSystemSettings
|
|
@EnvironmentObject private var storeKitManager: StoreKitManager
|
|
|
|
@StateObject private var viewModel: CableProPaywallViewModel
|
|
@State private var alertInfo: PaywallAlert?
|
|
|
|
private static let defaultProductIds = StoreKitManager.subscriptionProductIDs
|
|
|
|
init(isPresented: Binding<Bool>, productIdentifiers: [String] = CableProPaywallView.defaultProductIds) {
|
|
_isPresented = isPresented
|
|
_viewModel = StateObject(wrappedValue: CableProPaywallViewModel(productIdentifiers: productIdentifiers))
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 24) {
|
|
header
|
|
featureList
|
|
plansSection
|
|
footer
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 28)
|
|
.padding(.bottom, 16)
|
|
.navigationTitle("Cable PRO")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Close") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
.task {
|
|
await viewModel.loadProducts(force: true)
|
|
await storeKitManager.refreshEntitlements()
|
|
}
|
|
.refreshable {
|
|
await viewModel.loadProducts(force: true)
|
|
await storeKitManager.refreshEntitlements()
|
|
}
|
|
}
|
|
.onChange(of: viewModel.alert) { newValue in
|
|
alertInfo = newValue
|
|
}
|
|
.alert(item: $alertInfo) { alert in
|
|
Alert(
|
|
title: Text(alert.title),
|
|
message: Text(alert.messageText),
|
|
dismissButton: .default(Text("OK")) {
|
|
viewModel.alert = nil
|
|
alertInfo = nil
|
|
}
|
|
)
|
|
}
|
|
.onChange(of: viewModel.purchasedProductIDs) { newValue in
|
|
Task { await storeKitManager.refreshEntitlements() }
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(localizedString("cable.pro.paywall.title", defaultValue: "Unlock Cable PRO"))
|
|
.font(.largeTitle.bold())
|
|
Text(localizedString("cable.pro.paywall.subtitle", defaultValue: "Cable PRO enables more configuration options for loads, batteries and chargers."))
|
|
.font(.body)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private var featureList: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
paywallFeature(text: localizedString("cable.pro.feature.dutyCycle", defaultValue: "Duty-cycle aware cable calculators"), icon: "bolt.fill")
|
|
paywallFeature(text: localizedString("cable.pro.feature.batteryCapacity", defaultValue: "Configure usable battery capacity"), icon: "list.clipboard")
|
|
paywallFeature(text: localizedString("cable.pro.feature.usageBased", defaultValue: "Usage based calculations"), icon: "sparkles")
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private func paywallFeature(text: String, icon: String) -> some View {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: icon)
|
|
.font(.headline)
|
|
.foregroundStyle(Color.accentColor)
|
|
.frame(width: 28, height: 28)
|
|
.background(
|
|
Circle()
|
|
.fill(Color.accentColor.opacity(0.12))
|
|
)
|
|
Text(text)
|
|
.font(.callout)
|
|
.foregroundStyle(.primary)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var plansSection: some View {
|
|
switch viewModel.state {
|
|
case .idle, .loading:
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.fill(Color(.secondarySystemBackground))
|
|
.frame(height: 140)
|
|
.overlay(ProgressView())
|
|
.frame(maxWidth: .infinity)
|
|
case .failed(let message):
|
|
VStack(spacing: 12) {
|
|
Text("We couldn't load Cable PRO at the moment.")
|
|
.font(.headline)
|
|
Text(message)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Button(action: { Task { await viewModel.loadProducts(force: true) } }) {
|
|
Text("Try Again")
|
|
.font(.callout.weight(.semibold))
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
.padding()
|
|
|
|
case .loaded:
|
|
if viewModel.products.isEmpty {
|
|
VStack(spacing: 12) {
|
|
Text("No plans are currently available.")
|
|
.font(.headline)
|
|
Text("Check back soon—Cable PRO launches in your region shortly.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding()
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.fill(Color(.secondarySystemBackground))
|
|
)
|
|
} else {
|
|
VStack(spacing: 12) {
|
|
let hasActiveSubscription = !viewModel.purchasedProductIDs.isEmpty
|
|
|
|
ForEach(viewModel.products) { product in
|
|
PlanCard(
|
|
product: product,
|
|
isProcessing: viewModel.purchasingProductID == product.id,
|
|
isPurchased: viewModel.purchasedProductIDs.contains(product.id),
|
|
isDisabled: hasActiveSubscription && !viewModel.purchasedProductIDs.contains(product.id)
|
|
) {
|
|
Task {
|
|
await viewModel.purchase(product)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var footer: some View {
|
|
VStack(spacing: 12) {
|
|
Button {
|
|
Task {
|
|
await viewModel.restorePurchases()
|
|
}
|
|
} label: {
|
|
if viewModel.isRestoring {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity)
|
|
} else {
|
|
Text(localizedString("cable.pro.restore.button", defaultValue: "Restore Purchases"))
|
|
.font(.footnote.weight(.semibold))
|
|
}
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.padding(.top, 8)
|
|
.disabled(viewModel.isRestoring)
|
|
|
|
HStack(spacing: 16) {
|
|
if let termsURL = localizedURL(forKey: "cable.pro.terms.url") {
|
|
Link(localizedString("cable.pro.terms.label", defaultValue: "Terms"), destination: termsURL)
|
|
}
|
|
if let privacyURL = localizedURL(forKey: "cable.pro.privacy.url") {
|
|
Link(localizedString("cable.pro.privacy.label", defaultValue: "Privacy"), destination: privacyURL)
|
|
}
|
|
}
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
private func localizedURL(forKey key: String) -> URL? {
|
|
let raw = localizedString(key, defaultValue: "")
|
|
guard let url = URL(string: raw), !raw.isEmpty else { return nil }
|
|
return url
|
|
}
|
|
}
|
|
|
|
private func localizedString(_ key: String, defaultValue: String) -> String {
|
|
NSLocalizedString(key, tableName: nil, bundle: .main, value: defaultValue, comment: "")
|
|
}
|
|
|
|
private func localizedDurationString(for period: Product.SubscriptionPeriod) -> String {
|
|
let locale = Locale.autoupdatingCurrent
|
|
let number = localizedNumber(period.value, locale: locale)
|
|
|
|
let unitBase: String
|
|
switch period.unit {
|
|
case .day: unitBase = "day"
|
|
case .week: unitBase = "week"
|
|
case .month: unitBase = "month"
|
|
case .year: unitBase = "year"
|
|
@unknown default: unitBase = "day"
|
|
}
|
|
|
|
if period.value == 1 {
|
|
let key = "cable.pro.duration.\(unitBase).singular"
|
|
return localizedString(key, defaultValue: singularDurationFallback(for: unitBase))
|
|
} else {
|
|
let key = "cable.pro.duration.\(unitBase).plural"
|
|
let template = localizedString(key, defaultValue: pluralDurationFallback(for: unitBase))
|
|
return String(format: template, number)
|
|
}
|
|
}
|
|
|
|
private func localizedNumber(_ value: Int, locale: Locale) -> String {
|
|
let formatter = NumberFormatter()
|
|
formatter.locale = locale
|
|
formatter.numberStyle = .decimal
|
|
return formatter.string(from: NSNumber(value: value)) ?? String(value)
|
|
}
|
|
|
|
private func singularDurationFallback(for unit: String) -> String {
|
|
switch unit {
|
|
case "day": return "every day"
|
|
case "week": return "every week"
|
|
case "month": return "every month"
|
|
case "year": return "every year"
|
|
default: return "every day"
|
|
}
|
|
}
|
|
|
|
private func pluralDurationFallback(for unit: String) -> String {
|
|
switch unit {
|
|
case "day": return "every %@ days"
|
|
case "week": return "every %@ weeks"
|
|
case "month": return "every %@ months"
|
|
case "year": return "every %@ years"
|
|
default: return "every %@ days"
|
|
}
|
|
}
|
|
|
|
private func trialDurationString(for period: Product.SubscriptionPeriod) -> String {
|
|
let locale = Locale.autoupdatingCurrent
|
|
let number = localizedNumber(period.value, locale: locale)
|
|
|
|
let unitBase: String
|
|
switch period.unit {
|
|
case .day: unitBase = "day"
|
|
case .week: unitBase = "week"
|
|
case .month: unitBase = "month"
|
|
case .year: unitBase = "year"
|
|
@unknown default: unitBase = "day"
|
|
}
|
|
|
|
let key = "cable.pro.trial.duration.\(unitBase).\(period.value == 1 ? "singular" : "plural")"
|
|
|
|
let fallbackTemplate: String
|
|
switch unitBase {
|
|
case "day": fallbackTemplate = "%@-day"
|
|
case "week": fallbackTemplate = "%@-week"
|
|
case "month": fallbackTemplate = "%@-month"
|
|
case "year": fallbackTemplate = "%@-year"
|
|
default: fallbackTemplate = "%@-day"
|
|
}
|
|
|
|
let template = localizedString(key, defaultValue: fallbackTemplate)
|
|
if template.contains("%@") {
|
|
return String(format: template, number)
|
|
} else {
|
|
return template
|
|
}
|
|
}
|
|
|
|
private struct PlanCard: View {
|
|
let product: Product
|
|
let isProcessing: Bool
|
|
let isPurchased: Bool
|
|
let isDisabled: Bool
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
Text(product.displayName)
|
|
.font(.headline)
|
|
Spacer()
|
|
Text(product.displayPrice)
|
|
.font(.headline)
|
|
}
|
|
|
|
if let info = product.subscription {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
if let trial = trialDescription(for: info) {
|
|
Text(trial)
|
|
.font(.caption.weight(.semibold))
|
|
.padding(.vertical, 4)
|
|
.padding(.horizontal, 8)
|
|
.background(
|
|
Capsule()
|
|
.fill(Color.accentColor.opacity(0.15))
|
|
)
|
|
.foregroundStyle(Color.accentColor)
|
|
}
|
|
|
|
Text(subscriptionDescription(for: info))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Button(action: action) {
|
|
Group {
|
|
if isProcessing {
|
|
ProgressView()
|
|
} else if isPurchased {
|
|
Label(localizedString("cable.pro.button.unlocked", defaultValue: "Unlocked"), systemImage: "checkmark.circle.fill")
|
|
.labelStyle(.titleAndIcon)
|
|
} else {
|
|
let titleKey = product.subscription?.introductoryOffer != nil ? "cable.pro.button.freeTrial" : "cable.pro.button.unlock"
|
|
Text(localizedString(titleKey, defaultValue: product.subscription?.introductoryOffer != nil ? "Start Free Trial" : "Unlock Now"))
|
|
}
|
|
}
|
|
.font(.callout.weight(.semibold))
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 6)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(isProcessing || isPurchased || isDisabled)
|
|
.opacity((isPurchased || isDisabled) ? 0.6 : 1)
|
|
}
|
|
.padding()
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.fill(Color(.secondarySystemBackground))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.stroke(isPurchased ? Color.accentColor : Color.clear, lineWidth: isPurchased ? 2 : 0)
|
|
)
|
|
}
|
|
|
|
private func trialDescription(for info: Product.SubscriptionInfo) -> String? {
|
|
guard
|
|
let offer = info.introductoryOffer,
|
|
offer.paymentMode == .freeTrial
|
|
else { return nil }
|
|
|
|
let duration = trialDurationString(for: offer.period)
|
|
let template = localizedString("cable.pro.trial.badge", defaultValue: "Includes a %@ free trial")
|
|
return String(format: template, duration)
|
|
}
|
|
|
|
private func subscriptionDescription(for info: Product.SubscriptionInfo) -> String {
|
|
let quantity = localizedDurationString(for: info.subscriptionPeriod)
|
|
|
|
let templateKey: String
|
|
if let offer = info.introductoryOffer,
|
|
offer.paymentMode == .freeTrial {
|
|
templateKey = "cable.pro.subscription.trialThenRenews"
|
|
} else {
|
|
templateKey = "cable.pro.subscription.renews"
|
|
}
|
|
let template = localizedString(templateKey, defaultValue: templateKey == "cable.pro.subscription.trialThenRenews" ? "Free trial, then renews every %@." : "Renews every %@.")
|
|
return String(format: template, quantity)
|
|
}
|
|
}
|
|
|
|
struct PaywallAlert: Identifiable, Equatable {
|
|
enum Kind { case success, pending, restored, error }
|
|
|
|
let id = UUID()
|
|
let kind: Kind
|
|
let message: String
|
|
|
|
var title: String {
|
|
switch kind {
|
|
case .success:
|
|
return localizedString("cable.pro.alert.success.title", defaultValue: "Cable PRO Unlocked")
|
|
case .pending:
|
|
return localizedString("cable.pro.alert.pending.title", defaultValue: "Purchase Pending")
|
|
case .restored:
|
|
return localizedString("cable.pro.alert.restored.title", defaultValue: "Purchases Restored")
|
|
case .error:
|
|
return localizedString("cable.pro.alert.error.title", defaultValue: "Purchase Failed")
|
|
}
|
|
}
|
|
|
|
var messageText: String {
|
|
message
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
let unitSettings = UnitSystemSettings()
|
|
let manager = StoreKitManager(unitSettings: unitSettings)
|
|
return CableProPaywallView(isPresented: .constant(true))
|
|
.environmentObject(unitSettings)
|
|
.environmentObject(manager)
|
|
}
|