Files
honeyDueKMP/iosApp/iosApp/Subscription/UpgradePromptView.swift
Trey t 90c3858c90 Implement StoreKit 2 purchase flow with backend verification
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>
2025-11-24 13:46:33 -06:00

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