Files
honeyDueKMP/iosApp/iosApp/Subscription/FeatureComparisonView.swift
Trey t 7444f73b46 Close all 25 codex audit findings across KMP, iOS, and Android
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>
2026-02-18 13:15:34 -06:00

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