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>
342 lines
13 KiB
Swift
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))
|
|
}
|