Remediate all P0-S priority findings from cross-platform architecture audit: - Harden token storage with EncryptedSharedPreferences (Android) and Keychain (iOS) - Add SSL pinning and certificate validation to API clients - Fix subscription cache race conditions and add thread-safe access - Add input validation for document uploads and file type restrictions - Refactor DocumentApi to use proper multipart upload flow - Add rate limiting awareness and retry logic to API layer - Harden subscription tier enforcement in SubscriptionHelper - Add biometric prompt for sensitive actions (Login, Onboarding) - Fix notification permission handling and device registration - Add UI test infrastructure (page objects, fixtures, smoke tests) - Add CI workflow for mobile builds Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
299 lines
11 KiB
Swift
299 lines
11 KiB
Swift
import SwiftUI
|
|
import ComposeApp
|
|
import StoreKit
|
|
|
|
struct FeatureComparisonView: View {
|
|
@Binding var isPresented: Bool
|
|
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
|
@StateObject private var storeKit = StoreKitManager.shared
|
|
@State private var showUpgradePrompt = false
|
|
@State private var selectedProduct: Product?
|
|
@State private var isProcessing = false
|
|
@State private var errorMessage: String?
|
|
@State private var showSuccessAlert = false
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(spacing: AppSpacing.xl) {
|
|
// Header
|
|
VStack(spacing: AppSpacing.sm) {
|
|
Text("Choose Your Plan")
|
|
.font(.title.weight(.bold))
|
|
.foregroundColor(Color.appTextPrimary)
|
|
|
|
Text("Upgrade to Pro for unlimited access")
|
|
.font(.subheadline)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
.padding(.top, AppSpacing.lg)
|
|
|
|
// Feature Comparison Table
|
|
VStack(spacing: 0) {
|
|
// Header Row
|
|
HStack {
|
|
Text("Feature")
|
|
.font(.headline)
|
|
.foregroundColor(Color.appTextPrimary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
Text("Free")
|
|
.font(.headline)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
.frame(width: 80)
|
|
|
|
Text("Pro")
|
|
.font(.headline)
|
|
.foregroundColor(Color.appPrimary)
|
|
.frame(width: 80)
|
|
}
|
|
.padding()
|
|
.background(Color.appBackgroundSecondary)
|
|
|
|
Divider()
|
|
|
|
// Feature Rows
|
|
ForEach(subscriptionCache.featureBenefits, id: \.featureName) { benefit in
|
|
ComparisonRow(
|
|
featureName: benefit.featureName,
|
|
freeText: benefit.freeTierText,
|
|
proText: benefit.proTierText
|
|
)
|
|
Divider()
|
|
}
|
|
|
|
// Default features if no data loaded
|
|
if subscriptionCache.featureBenefits.isEmpty {
|
|
ComparisonRow(featureName: "Properties", freeText: "1 property", proText: "Unlimited")
|
|
Divider()
|
|
ComparisonRow(featureName: "Tasks", freeText: "10 tasks", proText: "Unlimited")
|
|
Divider()
|
|
ComparisonRow(featureName: "Contractors", freeText: "Not available", proText: "Unlimited")
|
|
Divider()
|
|
ComparisonRow(featureName: "Documents", freeText: "Not available", proText: "Unlimited")
|
|
}
|
|
}
|
|
.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
|
|
SubscriptionButton(
|
|
product: product,
|
|
isSelected: selectedProduct?.id == product.id,
|
|
isProcessing: isProcessing,
|
|
onSelect: {
|
|
selectedProduct = product
|
|
handlePurchase(product)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
} else {
|
|
// Fallback if products fail to load
|
|
Button(action: {
|
|
Task { await storeKit.loadProducts() }
|
|
}) {
|
|
Text("Retry Loading Products")
|
|
.fontWeight(.semibold)
|
|
.frame(maxWidth: .infinity)
|
|
.foregroundColor(Color.appTextOnPrimary)
|
|
.padding()
|
|
.background(Color.appPrimary)
|
|
.cornerRadius(AppRadius.md)
|
|
}
|
|
.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)
|
|
}
|
|
|
|
// Restore Purchases
|
|
Button(action: {
|
|
handleRestore()
|
|
}) {
|
|
Text("Restore Purchases")
|
|
.font(.caption)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
.padding(.bottom, AppSpacing.xl)
|
|
}
|
|
}
|
|
.background(WarmGradientBackground())
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Close") {
|
|
isPresented = false
|
|
}
|
|
}
|
|
}
|
|
.alert("Subscription Active", isPresented: $showSuccessAlert) {
|
|
Button("Done") {
|
|
isPresented = false
|
|
}
|
|
} message: {
|
|
Text("You now have full access to all Pro features!")
|
|
}
|
|
.task {
|
|
await storeKit.loadProducts()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Purchase Handling
|
|
|
|
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 {
|
|
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"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Subscription Button
|
|
|
|
struct SubscriptionButton: 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 ComparisonRow: View {
|
|
let featureName: String
|
|
let freeText: String
|
|
let proText: String
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Text(featureName)
|
|
.font(.body)
|
|
.foregroundColor(Color.appTextPrimary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
Text(freeText)
|
|
.font(.subheadline)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
.frame(width: 80)
|
|
.multilineTextAlignment(.center)
|
|
|
|
Text(proText)
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundColor(Color.appPrimary)
|
|
.frame(width: 80)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
FeatureComparisonView(isPresented: .constant(true))
|
|
}
|