Files
honeyDueKMP/iosApp/iosApp/Subscription/FeatureComparisonView.swift
Trey t 9c574c4343 Harden iOS app with audit fixes, UI consistency, and sheet race condition fixes
Applies verified fixes from deep audit (concurrency, performance, security,
accessibility), standardizes CRUD form buttons to Add/Save pattern, removes
.drawingGroup() that broke search bar TextFields, and converts vulnerable
.sheet(isPresented:) + if-let patterns to safe presentation to prevent
blank white modals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:59:56 -06:00

342 lines
13 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
@StateObject private var purchaseHelper = SubscriptionPurchaseHelper()
@State private var showUpgradePrompt = false
/// Whether the user is already subscribed from a non-iOS platform
private var isSubscribedOnOtherPlatform: Bool {
guard let subscription = subscriptionCache.currentSubscription,
subscriptionCache.currentTier == "pro",
let source = subscription.subscriptionSource,
source != "ios" else {
return false
}
return true
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: AppSpacing.xl) {
// Trial banner
if let subscription = subscriptionCache.currentSubscription,
subscription.trialActive,
let trialEnd = subscription.trialEnd {
HStack(spacing: 8) {
Image(systemName: "clock.fill")
.foregroundColor(Color.appAccent)
Text("Free trial ends \(DateUtils.formatDateMedium(trialEnd))")
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(Color.appAccent)
}
.padding()
.frame(maxWidth: .infinity)
.background(Color.appAccent.opacity(0.1))
.cornerRadius(AppRadius.md)
.padding(.horizontal)
.padding(.top, AppSpacing.lg)
}
// 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 isSubscribedOnOtherPlatform {
// User is subscribed on another platform
CrossPlatformSubscriptionNotice(
source: subscriptionCache.currentSubscription?.subscriptionSource ?? ""
)
.padding(.horizontal)
} else 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: purchaseHelper.selectedProduct?.id == product.id,
isProcessing: purchaseHelper.isProcessing,
onSelect: {
purchaseHelper.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 = purchaseHelper.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
if !isSubscribedOnOtherPlatform {
Button(action: {
purchaseHelper.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: $purchaseHelper.showSuccessAlert) {
Button("Done") {
isPresented = false
}
} message: {
Text("You now have full access to all Pro features!")
}
.task {
await storeKit.loadProducts()
}
}
}
}
// MARK: - Subscription Button
struct SubscriptionButton: View {
let product: Product
let isSelected: Bool
let isProcessing: Bool
let onSelect: () -> Void
var isAnnual: Bool {
product.subscription?.subscriptionPeriod.unit == .year
}
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()
}
}
// MARK: - Cross-Platform Subscription Notice
struct CrossPlatformSubscriptionNotice: View {
let source: String
var body: some View {
VStack(spacing: AppSpacing.md) {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 36))
.foregroundColor(Color.appPrimary)
Text("You're already subscribed")
.font(.headline)
.foregroundColor(Color.appTextPrimary)
if source == "stripe" {
Text("Manage your subscription at casera.app")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
Button(action: {
if let url = URL(string: "https://casera.app/settings") {
UIApplication.shared.open(url)
}
}) {
Label("Open casera.app", systemImage: "globe")
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.foregroundColor(Color.appTextOnPrimary)
.padding()
.background(Color.appPrimary)
.cornerRadius(AppRadius.md)
}
} else if source == "android" {
Text("Your subscription is managed through Google Play on your Android device.")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
} else {
Text("Your subscription is managed on another platform.")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
}
.padding()
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.lg)
}
}
#Preview {
FeatureComparisonView(isPresented: .constant(true))
}