Files
honeyDueKMP/iosApp/iosApp/Subscription/UpgradeFeatureView.swift
T
Trey T db65db6232
Android UI Tests / ui-tests (push) Has been cancelled
i18n: complete app-wide localization (10 languages) + audit tooling
Localize all user-facing strings across iOS (SwiftUI), shared Kotlin, and
Android Compose into en/es/fr/de/pt/it/ja/ko/nl/zh:
- iOS String Catalogs: main + widget Localizable.xcstrings, InfoPlist.xcstrings
  (permissions), plural variations, ~200 new keys translated
- Shared Kotlin ClientStrings table + Android composeResources/values-* (884 keys
  ×10), routed Api/ViewModel/util error & UI strings through localization
- Backend-localized lookups/suggestions consumed via display names
- Widget extension catalog; theme names, home-profile fallbacks, validation,
  network errors, accessibility labels all localized

Add re-runnable verification gates:
- scripts/i18n_audit.py  — enumerate every literal, partition to GAP=0
- scripts/i18n_coverage.py — all 10 locales translated, format-specifier parity

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:52:28 -05:00

316 lines
13 KiB
Swift

import SwiftUI
import ComposeApp
import StoreKit
struct UpgradeFeatureView: View {
let triggerKey: String
let icon: String
@State private var showFeatureComparison = false
@State private var isAnimating = false
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@StateObject private var storeKit = StoreKitManager.shared
@StateObject private var purchaseHelper = SubscriptionPurchaseHelper()
private var triggerData: UpgradeTriggerData? {
subscriptionCache.upgradeTriggers[triggerKey]
}
private var title: String {
triggerData?.title ?? String(localized: "Upgrade Required")
}
private var message: String {
triggerData?.message ?? String(localized: "This feature is available with a Pro subscription.")
}
private var buttonText: String {
triggerData?.buttonText ?? String(localized: "Upgrade to Pro")
}
/// 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 {
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: icon)
.font(.system(size: 36, weight: .medium))
.foregroundColor(.white)
}
.naturalShadow(.pronounced)
}
.padding(.top, OrganicSpacing.comfortable)
VStack(spacing: 8) {
Text(title)
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center)
.a11yHeader()
Text(message)
.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) {
OrganicUpgradeFeatureRow(icon: "outline", text: String(localized: "Unlimited properties"))
OrganicUpgradeFeatureRow(icon: "checkmark.circle.fill", text: String(localized: "Unlimited tasks"))
OrganicUpgradeFeatureRow(icon: "person.2.fill", text: String(localized: "Contractor management"))
OrganicUpgradeFeatureRow(icon: "doc.fill", text: String(localized: "Document & warranty storage"))
}
}
}
.padding(OrganicSpacing.cozy)
.background(OrganicUpgradeCardBackground())
.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
SubscriptionProductButton(
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)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(WarmGradientBackground().a11yDecorative())
.sheet(isPresented: $showFeatureComparison) {
FeatureComparisonView(isPresented: $showFeatureComparison)
}
.alert("Subscription Active", isPresented: $purchaseHelper.showSuccessAlert) {
Button("Done") { }
} 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 OrganicUpgradeFeatureRow: 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 == "outline" {
Image("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(LocalizedStringKey(text))
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextPrimary)
Spacer()
}
}
}
// MARK: - Organic Card Background
private struct OrganicUpgradeCardBackground: View {
@Environment(\.colorScheme) var colorScheme
var body: some View {
ZStack {
Color.appBackgroundSecondary
GeometryReader { geo in
OrganicBlobShape(variation: 2)
.fill(
RadialGradient(
colors: [
Color.appAccent.opacity(colorScheme == .dark ? 0.08 : 0.05),
Color.appAccent.opacity(0.01)
],
center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.5
)
)
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.6)
.offset(x: -geo.size.width * 0.1, y: geo.size.height * 0.4)
.blur(radius: 20)
}
GrainTexture(opacity: 0.015)
}
}
}
#Preview {
UpgradeFeatureView(
triggerKey: "view_contractors",
icon: "person.2.fill"
)
}