Files
honeyDueKMP/iosApp/iosApp/Subscription/UpgradePromptView.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

519 lines
21 KiB
Swift

import SwiftUI
import ComposeApp
import StoreKit
// MARK: - Promo Content View
struct PromoContentView: View {
let content: String
private let lines: [PromoLine]
init(content: String) {
self.content = content
self.lines = Self.parseContent(content)
}
var body: some View {
VStack(spacing: 12) {
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
switch line {
case .emoji(let text):
Text(text)
.font(.system(size: 36))
case .title(let text):
Text(text)
.font(.system(size: 18, weight: .bold, design: .rounded))
.foregroundColor(Color.appPrimary)
.multilineTextAlignment(.center)
case .body(let text):
Text(text)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
case .checkItem(let text):
HStack(alignment: .top, spacing: 10) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 24, height: 24)
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundColor(Color.appPrimary)
}
Text(text)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextPrimary)
Spacer()
}
case .italic(let text):
Text(text)
.font(.system(size: 12, weight: .medium))
.italic()
.foregroundColor(Color.appAccent)
.multilineTextAlignment(.center)
case .spacer:
Spacer().frame(height: 4)
}
}
}
}
private enum PromoLine {
case emoji(String)
case title(String)
case body(String)
case checkItem(String)
case italic(String)
case spacer
}
private static func parseContent(_ content: String) -> [PromoLine] {
var result: [PromoLine] = []
let lines = content.components(separatedBy: "\n")
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty {
result.append(.spacer)
} else if trimmed.hasPrefix("") {
let text = trimmed.dropFirst().trimmingCharacters(in: .whitespaces)
result.append(.checkItem(text))
} else if trimmed.contains("<b>") && trimmed.contains("</b>") {
let cleaned = trimmed
.replacingOccurrences(of: "<b>", with: "")
.replacingOccurrences(of: "</b>", with: "")
if let firstScalar = cleaned.unicodeScalars.first,
firstScalar.properties.isEmoji && !firstScalar.properties.isASCIIHexDigit {
let parts = cleaned.split(separator: " ", maxSplits: 1)
if parts.count == 2 {
result.append(.emoji(String(parts[0])))
result.append(.title(String(parts[1])))
} else {
result.append(.title(cleaned))
}
} else {
result.append(.title(cleaned))
}
} else if trimmed.hasPrefix("<i>") && trimmed.hasSuffix("</i>") {
let text = trimmed
.replacingOccurrences(of: "<i>", with: "")
.replacingOccurrences(of: "</i>", with: "")
result.append(.italic(text))
} else if trimmed.first?.unicodeScalars.first?.properties.isEmoji == true &&
trimmed.count <= 2 {
result.append(.emoji(trimmed))
} else {
result.append(.body(trimmed))
}
}
return result
}
}
struct UpgradePromptView: View {
let triggerKey: String
@Binding var isPresented: Bool
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@StateObject private var storeKit = StoreKitManager.shared
@StateObject private var purchaseHelper = SubscriptionPurchaseHelper()
@State private var showFeatureComparison = false
@State private var isAnimating = false
var triggerData: UpgradeTriggerData? {
subscriptionCache.upgradeTriggers[triggerKey]
}
/// 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 {
ZStack {
WarmGradientBackground()
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.comfortable) {
// 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))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.padding(.horizontal, 16)
}
// Hero Section
VStack(spacing: OrganicSpacing.comfortable) {
ZStack {
Circle()
.fill(
RadialGradient(
colors: [
Color.appAccent.opacity(0.2),
Color.appAccent.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 80
)
)
.frame(width: 160, height: 160)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
value: isAnimating
)
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.appAccent, Color.appAccent.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 80, height: 80)
Image(systemName: "star.fill")
.font(.system(size: 36, weight: .medium))
.foregroundColor(.white)
}
.naturalShadow(.pronounced)
}
.padding(.top, OrganicSpacing.comfortable)
VStack(spacing: 8) {
Text(triggerData?.title ?? "Upgrade to Pro")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center)
Text(triggerData?.message ?? "Unlock unlimited access to all features")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
}
// Features Card
VStack(spacing: 16) {
if let promoContent = triggerData?.promoHtml, !promoContent.isEmpty {
PromoContentView(content: promoContent)
} else {
VStack(alignment: .leading, spacing: 14) {
OrganicFeatureRow(icon: "house_outline", text: "Unlimited properties")
OrganicFeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks")
OrganicFeatureRow(icon: "person.2.fill", text: "Contractor management")
OrganicFeatureRow(icon: "doc.fill", text: "Document & warranty storage")
}
}
}
.padding(OrganicSpacing.cozy)
.background(OrganicCardBackground())
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.naturalShadow(.medium)
.padding(.horizontal, 16)
// Subscription Products
if isSubscribedOnOtherPlatform {
CrossPlatformSubscriptionNotice(
source: subscriptionCache.currentSubscription?.subscriptionSource ?? ""
)
.padding(.horizontal, 16)
} else {
VStack(spacing: 12) {
if storeKit.isLoading {
ProgressView()
.tint(Color.appPrimary)
.padding()
} else if !storeKit.products.isEmpty {
ForEach(storeKit.products, id: \.id) { product in
OrganicSubscriptionButton(
product: product,
isSelected: purchaseHelper.selectedProduct?.id == product.id,
isProcessing: purchaseHelper.isProcessing,
onSelect: {
purchaseHelper.handlePurchase(product)
}
)
}
} else {
Button(action: {
Task { await storeKit.loadProducts() }
}) {
HStack(spacing: 8) {
Image(systemName: "arrow.clockwise")
Text("Retry Loading Products")
.font(.system(size: 16, weight: .semibold))
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(Color.appPrimary)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
}
.padding(.horizontal, 16)
}
// Error Message
if let error = purchaseHelper.errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(error)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError)
Spacer()
}
.padding(16)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.padding(.horizontal, 16)
}
// Links
VStack(spacing: 12) {
Button(action: {
showFeatureComparison = true
}) {
Text("Compare Free vs Pro")
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
if !isSubscribedOnOtherPlatform {
Button(action: {
purchaseHelper.handleRestore()
}) {
Text("Restore Purchases")
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
}
}
.padding(.bottom, OrganicSpacing.airy)
}
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(action: { isPresented = false }) {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appTextSecondary)
.padding(8)
.background(Color.appBackgroundSecondary.opacity(0.8))
.clipShape(Circle())
}
}
}
.sheet(isPresented: $showFeatureComparison) {
FeatureComparisonView(isPresented: $showFeatureComparison)
}
.alert("Subscription Active", isPresented: $purchaseHelper.showSuccessAlert) {
Button("Done") {
isPresented = false
}
} message: {
Text("You now have full access to all Pro features!")
}
.task {
subscriptionCache.refreshFromCache()
await storeKit.loadProducts()
}
.onAppear {
isAnimating = true
}
}
}
}
// MARK: - Organic Feature Row
private struct OrganicFeatureRow: View {
let icon: String
let text: String
var body: some View {
HStack(spacing: 14) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 36, height: 36)
if icon == "house_outline" {
Image("house_outline")
.renderingMode(.template)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 18, height: 18)
.foregroundColor(Color.appPrimary)
} else {
Image(systemName: icon)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
}
Text(text)
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextPrimary)
Spacer()
}
}
}
// MARK: - Organic Subscription Button
private struct OrganicSubscriptionButton: View {
let product: Product
let isSelected: Bool
let isProcessing: Bool
let onSelect: () -> Void
@Environment(\.colorScheme) var colorScheme
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(.system(size: 17, weight: .semibold))
.foregroundColor(isAnnual ? Color.appTextOnPrimary : Color.appTextPrimary)
if let savings = savingsText {
Text(savings)
.font(.system(size: 12, weight: .bold))
.foregroundColor(isAnnual ? Color.white.opacity(0.9) : Color.appPrimary)
}
}
Spacer()
if isProcessing && isSelected {
ProgressView()
.tint(isAnnual ? .white : Color.appPrimary)
} else {
Text(product.displayPrice)
.font(.system(size: 20, weight: .bold, design: .rounded))
.foregroundColor(isAnnual ? Color.appTextOnPrimary : Color.appPrimary)
}
}
.padding(18)
.frame(maxWidth: .infinity)
.background(
ZStack {
if isAnnual {
LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
} else {
Color.appBackgroundSecondary
}
if !isAnnual {
GrainTexture(opacity: 0.01)
}
}
)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(isAnnual ? Color.appAccent : Color.appTextSecondary.opacity(0.15), lineWidth: isAnnual ? 2 : 1)
)
.shadow(
color: isAnnual ? Color.appPrimary.opacity(0.3) : Color.black.opacity(colorScheme == .dark ? 0.3 : 0.08),
radius: isAnnual ? 12 : 8,
y: isAnnual ? 6 : 4
)
}
.disabled(isProcessing)
}
}
struct SubscriptionProductButton: 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 {
OrganicSubscriptionButton(
product: product,
isSelected: isSelected,
isProcessing: isProcessing,
onSelect: onSelect
)
}
}
struct FeatureRow: View {
let icon: String
let text: String
var body: some View {
OrganicFeatureRow(icon: icon, text: text)
}
}
#Preview {
UpgradePromptView(triggerKey: "add_second_property", isPresented: .constant(true))
}