Fully implemented StoreKit 2 in-app purchase system: StoreKitManager.swift: - Product loading from App Store with Product.products() API - Complete purchase flow with result handling (success/cancelled/pending) - Transaction verification using VerificationResult - Backend receipt verification via SubscriptionApi - Automatic transaction observation with Transaction.updates - Current entitlements checking with Transaction.currentEntitlements - Restore purchases with AppStore.sync() - Transaction finishing to acknowledge purchases - Subscription cache updates after successful verification - Error handling with custom StoreKitError enum UpgradePromptView.swift: - Integration with StoreKitManager singleton - Automatic product loading on view appear - Display of subscription options with real pricing - Product selection with loading states - Purchase flow with try/catch error handling - Success alert on purchase completion - Error message display for failed purchases - Restore purchases button - SubscriptionProductButton component for product display - Annual subscription highlighted with "Save 17%" badge - Retry loading if products fail to fetch Key features: - Async/await pattern throughout - MainActor dispatching for UI updates - Transaction cryptographic verification - Backend verification sends transaction ID - Purchased product IDs tracking - Transaction listener cleanup in deinit - Products sorted by price (monthly first) Ready for testing with Configuration.storekit in simulator. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
284 lines
9.9 KiB
Swift
284 lines
9.9 KiB
Swift
import SwiftUI
|
|
import ComposeApp
|
|
import StoreKit
|
|
|
|
struct UpgradePromptView: View {
|
|
let triggerKey: String
|
|
@Binding var isPresented: Bool
|
|
|
|
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
|
@StateObject private var storeKit = StoreKitManager.shared
|
|
@State private var showFeatureComparison = false
|
|
@State private var isProcessing = false
|
|
@State private var selectedProduct: Product?
|
|
@State private var errorMessage: String?
|
|
@State private var showSuccessAlert = false
|
|
|
|
var triggerData: UpgradeTriggerData? {
|
|
subscriptionCache.upgradeTriggers[triggerKey]
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(spacing: AppSpacing.xl) {
|
|
// Icon
|
|
Image(systemName: "star.circle.fill")
|
|
.font(.system(size: 60))
|
|
.foregroundStyle(Color.appAccent.gradient)
|
|
.padding(.top, AppSpacing.xl)
|
|
|
|
// Title
|
|
Text(triggerData?.title ?? "Upgrade to Pro")
|
|
.font(.title2.weight(.bold))
|
|
.foregroundColor(Color.appTextPrimary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal)
|
|
|
|
// Message
|
|
Text(triggerData?.message ?? "Unlock unlimited access to all features")
|
|
.font(.body)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal)
|
|
|
|
// Pro Features Preview
|
|
VStack(alignment: .leading, spacing: AppSpacing.md) {
|
|
FeatureRow(icon: "house.fill", text: "Unlimited properties")
|
|
FeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks")
|
|
FeatureRow(icon: "person.2.fill", text: "Contractor management")
|
|
FeatureRow(icon: "doc.fill", text: "Document & warranty storage")
|
|
}
|
|
.padding()
|
|
.background(Color.appBackgroundSecondary)
|
|
.cornerRadius(AppRadius.lg)
|
|
.padding(.horizontal)
|
|
|
|
// Subscription Products
|
|
if storeKit.isLoading {
|
|
ProgressView()
|
|
.tint(Color.appPrimary)
|
|
.padding()
|
|
} else if !storeKit.products.isEmpty {
|
|
VStack(spacing: AppSpacing.md) {
|
|
ForEach(storeKit.products, id: \.id) { product in
|
|
SubscriptionProductButton(
|
|
product: product,
|
|
isSelected: selectedProduct?.id == product.id,
|
|
isProcessing: isProcessing,
|
|
onSelect: {
|
|
selectedProduct = product
|
|
handlePurchase(product)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
} else {
|
|
// Fallback upgrade button if products fail to load
|
|
Button(action: {
|
|
Task { await storeKit.loadProducts() }
|
|
}) {
|
|
HStack {
|
|
if isProcessing {
|
|
ProgressView()
|
|
.tint(Color.appTextOnPrimary)
|
|
} else {
|
|
Text("Retry Loading Products")
|
|
.fontWeight(.semibold)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.foregroundColor(Color.appTextOnPrimary)
|
|
.padding()
|
|
.background(Color.appPrimary)
|
|
.cornerRadius(AppRadius.md)
|
|
}
|
|
.disabled(isProcessing)
|
|
.padding(.horizontal)
|
|
}
|
|
|
|
// Error Message
|
|
if let error = errorMessage {
|
|
HStack {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundColor(Color.appError)
|
|
Text(error)
|
|
.font(.subheadline)
|
|
.foregroundColor(Color.appError)
|
|
}
|
|
.padding()
|
|
.background(Color.appError.opacity(0.1))
|
|
.cornerRadius(AppRadius.md)
|
|
.padding(.horizontal)
|
|
}
|
|
|
|
// Compare Plans
|
|
Button(action: {
|
|
showFeatureComparison = true
|
|
}) {
|
|
Text("Compare Free vs Pro")
|
|
.font(.subheadline)
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
|
|
// Restore Purchases
|
|
Button(action: {
|
|
handleRestore()
|
|
}) {
|
|
Text("Restore Purchases")
|
|
.font(.caption)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
.padding(.bottom, AppSpacing.xl)
|
|
}
|
|
}
|
|
.background(Color.appBackgroundPrimary)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") {
|
|
isPresented = false
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showFeatureComparison) {
|
|
FeatureComparisonView(isPresented: $showFeatureComparison)
|
|
}
|
|
.alert("Subscription Active", isPresented: $showSuccessAlert) {
|
|
Button("Done") {
|
|
isPresented = false
|
|
}
|
|
} message: {
|
|
Text("You now have full access to all Pro features!")
|
|
}
|
|
.task {
|
|
await storeKit.loadProducts()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handlePurchase(_ product: Product) {
|
|
isProcessing = true
|
|
errorMessage = nil
|
|
|
|
Task {
|
|
do {
|
|
let transaction = try await storeKit.purchase(product)
|
|
|
|
await MainActor.run {
|
|
isProcessing = false
|
|
|
|
if transaction != nil {
|
|
// Purchase successful
|
|
showSuccessAlert = true
|
|
}
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
isProcessing = false
|
|
errorMessage = "Purchase failed: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleRestore() {
|
|
isProcessing = true
|
|
errorMessage = nil
|
|
|
|
Task {
|
|
await storeKit.restorePurchases()
|
|
|
|
await MainActor.run {
|
|
isProcessing = false
|
|
|
|
if !storeKit.purchasedProductIDs.isEmpty {
|
|
showSuccessAlert = true
|
|
} else {
|
|
errorMessage = "No purchases found to restore"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SubscriptionProductButton: View {
|
|
let product: Product
|
|
let isSelected: Bool
|
|
let isProcessing: Bool
|
|
let onSelect: () -> Void
|
|
|
|
var isAnnual: Bool {
|
|
product.id.contains("annual")
|
|
}
|
|
|
|
var savingsText: String? {
|
|
if isAnnual {
|
|
return "Save 17%"
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var body: some View {
|
|
Button(action: onSelect) {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(product.displayName)
|
|
.font(.headline)
|
|
.foregroundColor(Color.appTextPrimary)
|
|
|
|
if let savings = savingsText {
|
|
Text(savings)
|
|
.font(.caption)
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if isProcessing && isSelected {
|
|
ProgressView()
|
|
.tint(Color.appTextOnPrimary)
|
|
} else {
|
|
Text(product.displayPrice)
|
|
.font(.title3.weight(.bold))
|
|
.foregroundColor(Color.appTextOnPrimary)
|
|
}
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity)
|
|
.background(isAnnual ? Color.appPrimary : Color.appSecondary)
|
|
.cornerRadius(AppRadius.md)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: AppRadius.md)
|
|
.stroke(isAnnual ? Color.appAccent : Color.clear, lineWidth: 2)
|
|
)
|
|
}
|
|
.disabled(isProcessing)
|
|
}
|
|
}
|
|
|
|
struct FeatureRow: View {
|
|
let icon: String
|
|
let text: String
|
|
|
|
var body: some View {
|
|
HStack(spacing: AppSpacing.md) {
|
|
Image(systemName: icon)
|
|
.foregroundColor(Color.appPrimary)
|
|
.frame(width: 24)
|
|
|
|
Text(text)
|
|
.font(.body)
|
|
.foregroundColor(Color.appTextPrimary)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
UpgradePromptView(triggerKey: "add_second_property", isPresented: .constant(true))
|
|
}
|