0b57812a6e
Guideline 3.1.2(c): Apple rechazo la 2.0.5 (77) por no tener enlaces funcionales a la politica de privacidad y los terminos de uso dentro del paywall. Anadidos en PremiumView y en el paywall del onboarding (mealmood.app/privacy.html y terms.html), y ambos enlaces al final de la descripcion del App Store en los 6 idiomas. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
483 lines
19 KiB
Swift
483 lines
19 KiB
Swift
import SwiftUI
|
|
import StoreKit
|
|
|
|
/// Wrapper for presenting the paywall via `sheet(item:)` so the attribution
|
|
/// source travels with the presentation (two separate @State vars sometimes
|
|
/// rendered the sheet before the source was set — GA4 logged "unknown").
|
|
struct PaywallPresentation: Identifiable {
|
|
let id = UUID()
|
|
let source: String
|
|
}
|
|
|
|
struct PremiumView: View {
|
|
@Environment(\.modelContext) private var context
|
|
@StateObject private var storeManager = StoreManager()
|
|
@State private var purchaseStatusMessageKey: String?
|
|
@State private var appearedAt: Date = Date()
|
|
@State private var didConvert: Bool = false
|
|
@State private var selectedPlan: PlanChoice = .yearly
|
|
@State private var showOfferCodeRedemption: Bool = false
|
|
|
|
let settings: AppSettings
|
|
var source: String = "unknown"
|
|
|
|
enum PlanChoice {
|
|
case monthly, yearly, lifetime
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.mealMoodBackground.ignoresSafeArea()
|
|
|
|
ScrollView {
|
|
VStack(spacing: 22) {
|
|
VStack(spacing: 12) {
|
|
AppIconPlaceholder(size: 84)
|
|
|
|
Text("premium_title")
|
|
.font(.mealMoodH1)
|
|
.foregroundColor(.mealMoodTextPrimary)
|
|
|
|
Text("premium_subtitle")
|
|
.font(.mealMoodBody)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
|
|
Text("premium_social_proof")
|
|
.font(.mealMoodCaption)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
.padding(.top, 2)
|
|
}
|
|
.padding(.top, 24)
|
|
.padding(.horizontal, 24)
|
|
|
|
if settings.isPremium {
|
|
Label("premium_active", systemImage: "checkmark.seal.fill")
|
|
.font(.mealMoodBodyBold)
|
|
.foregroundColor(.mealMoodSuccess)
|
|
.padding(.horizontal, 24)
|
|
} else {
|
|
planSelector
|
|
.padding(.horizontal, 24)
|
|
|
|
ctaButton
|
|
.padding(.horizontal, 24)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
BenefitItem(text: String(localized: "premium_no_ads"))
|
|
BenefitItem(text: String(localized: "premium_unlimited_dishes"))
|
|
BenefitItem(text: String(localized: "premium_advanced_rules"))
|
|
BenefitItem(text: String(localized: "premium_future_weeks"))
|
|
BenefitItem(text: String(localized: "premium_share_week"))
|
|
BenefitItem(text: String(localized: "premium_family_sharing"))
|
|
}
|
|
.padding(16)
|
|
.background(Color.mealMoodSurface)
|
|
.cornerRadius(16)
|
|
.padding(.horizontal, 24)
|
|
|
|
if !settings.isPremium && storeManager.monthlyProduct == nil && storeManager.yearlyProduct == nil && storeManager.lifetimeProduct == nil {
|
|
productLoadFallback
|
|
.padding(.horizontal, 24)
|
|
|
|
#if DEBUG
|
|
let hasStoreKitFileInBundle = Bundle.main.url(forResource: "MealMood", withExtension: "storekit") != nil
|
|
let hasStoreKitLaunchArgument = ProcessInfo.processInfo.arguments
|
|
.contains { $0.localizedCaseInsensitiveContains("storekit") }
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Debug")
|
|
.font(.mealMoodCaption)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
Text("StoreKit file in bundle: \(hasStoreKitFileInBundle ? "yes" : "no")")
|
|
.font(.mealMoodCaption)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
Text("StoreKit launch arg: \(hasStoreKitLaunchArgument ? "yes" : "no")")
|
|
.font(.mealMoodCaption)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
Text("Bundle: \(Bundle.main.bundleIdentifier ?? "-")")
|
|
.font(.mealMoodCaption)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
Text("IDs: \(storeManager.debugProductIds.joined(separator: ", "))")
|
|
.font(.mealMoodCaption)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
Text("Loaded: \(storeManager.debugLoadedProductIds.joined(separator: ", "))")
|
|
.font(.mealMoodCaption)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
Text("Loaded detail: \(storeManager.debugLoadedProducts.joined(separator: ", "))")
|
|
.font(.mealMoodCaption)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
}
|
|
.padding(.horizontal, 24)
|
|
#endif
|
|
}
|
|
|
|
Button {
|
|
Task { await restorePurchases() }
|
|
} label: {
|
|
Text("premium_restore")
|
|
.font(.mealMoodSmall)
|
|
.foregroundColor(.mealMoodCoral)
|
|
}
|
|
.padding(.top, 4)
|
|
|
|
if !ProcessInfo.processInfo.isiOSAppOnMac {
|
|
Button {
|
|
showOfferCodeRedemption = true
|
|
} label: {
|
|
Text("premium_redeem_code")
|
|
.font(.mealMoodSmall)
|
|
.foregroundColor(.mealMoodCoral)
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
Text("premium_terms_note")
|
|
.font(.mealMoodCaption)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 24)
|
|
|
|
// Required by guideline 3.1.2(c): functional links to the
|
|
// privacy policy and Terms of Use inside the purchase flow.
|
|
HStack(spacing: 16) {
|
|
Link(destination: URL(string: "https://mealmood.app/privacy.html")!) {
|
|
Text("premium_privacy_link")
|
|
.font(.mealMoodCaption)
|
|
.underline()
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
}
|
|
Link(destination: URL(string: "https://mealmood.app/terms.html")!) {
|
|
Text("premium_terms_link")
|
|
.font(.mealMoodCaption)
|
|
.underline()
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
}
|
|
}
|
|
.padding(.bottom, 24)
|
|
}
|
|
.safeAreaPadding(.bottom, 20)
|
|
}
|
|
}
|
|
.navigationTitle("settings_premium")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.presentationDetents([.large])
|
|
.presentationDragIndicator(.visible)
|
|
.task {
|
|
await storeManager.loadProducts()
|
|
}
|
|
.offerCodeRedemption(isPresented: $showOfferCodeRedemption) { result in
|
|
if case .success = result {
|
|
// Entitlement propagates via Transaction.updates; force a re-check
|
|
// so premium unlocks immediately after redeeming.
|
|
Task { await storeManager.restorePurchases() }
|
|
}
|
|
}
|
|
.onAppear {
|
|
appearedAt = Date()
|
|
AnalyticsService.logPaywallViewed(source: source)
|
|
}
|
|
.onDisappear {
|
|
let seconds = Int(Date().timeIntervalSince(appearedAt))
|
|
AnalyticsService.logPaywallDismissed(source: source, secondsOnScreen: seconds, didConvert: didConvert)
|
|
}
|
|
.onChange(of: storeManager.isPremium) { _, isPremium in
|
|
if settings.isPremium != isPremium {
|
|
settings.isPremium = isPremium
|
|
try? context.save()
|
|
}
|
|
}
|
|
.alert("premium_title", isPresented: Binding(
|
|
get: { purchaseStatusMessageKey != nil },
|
|
set: { isPresented in
|
|
if !isPresented { purchaseStatusMessageKey = nil }
|
|
}
|
|
)) {
|
|
Button("dish_delete_blocked_ok", role: .cancel) {}
|
|
} message: {
|
|
Text(LocalizedStringKey(purchaseStatusMessageKey ?? ""))
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var planSelector: some View {
|
|
VStack(spacing: 10) {
|
|
HStack(spacing: 10) {
|
|
planChip(.yearly)
|
|
planChip(.monthly)
|
|
}
|
|
if storeManager.lifetimeProduct != nil {
|
|
planChip(.lifetime, fullWidth: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func planChip(_ plan: PlanChoice, fullWidth: Bool = false) -> some View {
|
|
let isSelected = selectedPlan == plan
|
|
let product: Product? = {
|
|
switch plan {
|
|
case .monthly: return storeManager.monthlyProduct
|
|
case .yearly: return storeManager.yearlyProduct
|
|
case .lifetime: return storeManager.lifetimeProduct
|
|
}
|
|
}()
|
|
let titleKey: LocalizedStringKey = {
|
|
switch plan {
|
|
case .monthly: return "premium_monthly"
|
|
case .yearly: return "premium_yearly"
|
|
case .lifetime: return "premium_lifetime"
|
|
}
|
|
}()
|
|
let priceSuffix: String = {
|
|
switch plan {
|
|
case .monthly: return " / " + String(localized: "premium_month")
|
|
case .yearly: return " / " + String(localized: "premium_year")
|
|
case .lifetime: return " · " + String(localized: "premium_one_time")
|
|
}
|
|
}()
|
|
|
|
Button {
|
|
selectedPlan = plan
|
|
HapticManager.shared.impact(style: .light)
|
|
} label: {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack {
|
|
Text(titleKey)
|
|
.font(.mealMoodBodyBold)
|
|
.foregroundColor(.mealMoodTextPrimary)
|
|
Spacer()
|
|
if plan == .yearly, let savings = storeManager.yearlySavingsPercent {
|
|
Text(String(format: String(localized: "premium_save_badge"), "\(savings)%"))
|
|
.font(.system(size: 10, weight: .bold))
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Color.mealMoodCoral)
|
|
.clipShape(Capsule())
|
|
}
|
|
if plan == .lifetime {
|
|
Text("premium_lifetime_badge")
|
|
.font(.system(size: 10, weight: .bold))
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Color.mealMoodTextPrimary)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
if let product = product {
|
|
Text(product.displayPrice + priceSuffix)
|
|
.font(.mealMoodSmall)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
} else {
|
|
Text("premium_loading_products")
|
|
.font(.mealMoodCaption)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
}
|
|
if plan == .yearly {
|
|
Text("premium_best_value")
|
|
.font(.system(size: 9, weight: .bold))
|
|
.foregroundColor(.mealMoodCoral)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(14)
|
|
.background(isSelected ? Color.mealMoodCoral.opacity(0.08) : Color.mealMoodSurface)
|
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.stroke(isSelected ? Color.mealMoodCoral : Color(hex: "#E0E0E0"), lineWidth: isSelected ? 2 : 1)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var ctaButton: some View {
|
|
let selectedProduct = currentSelectedProduct
|
|
VStack(spacing: 8) {
|
|
if selectedHasTrial {
|
|
Text("premium_trial_badge")
|
|
.font(.mealMoodCaption.weight(.semibold))
|
|
.foregroundColor(.mealMoodCoral)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(Color.mealMoodCoral.opacity(0.12))
|
|
.clipShape(Capsule())
|
|
}
|
|
PrimaryButton(
|
|
title: ctaTitle,
|
|
action: { if let p = selectedProduct { Task { await purchase(p) } } },
|
|
isEnabled: !storeManager.isLoading && selectedProduct != nil
|
|
)
|
|
if let product = selectedProduct, selectedHasTrial {
|
|
Text(String(format: String(localized: "premium_trial_note"),
|
|
product.displayPrice + " / " + String(localized: selectedPlan == .yearly ? "premium_year" : "premium_month")))
|
|
.font(.mealMoodCaption)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var currentSelectedProduct: Product? {
|
|
switch selectedPlan {
|
|
case .monthly: return storeManager.monthlyProduct
|
|
case .yearly: return storeManager.yearlyProduct
|
|
case .lifetime: return storeManager.lifetimeProduct
|
|
}
|
|
}
|
|
|
|
private var selectedHasTrial: Bool {
|
|
guard selectedPlan != .lifetime else { return false }
|
|
return storeManager.hasTrialAvailable
|
|
}
|
|
|
|
private var ctaTitle: String {
|
|
if storeManager.isLoading {
|
|
return String(localized: "premium_processing")
|
|
}
|
|
if selectedPlan == .lifetime {
|
|
return String(localized: "premium_buy_lifetime")
|
|
}
|
|
if selectedHasTrial {
|
|
return String(localized: "onboarding_paywall_cta_trial")
|
|
}
|
|
return String(localized: "premium_subscribe")
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var productLoadFallback: some View {
|
|
let text = fallbackContent(for: storeManager.productLoadState)
|
|
VStack(spacing: 10) {
|
|
Text(text.price)
|
|
.font(.mealMoodBodyBold)
|
|
.foregroundColor(.mealMoodTextPrimary)
|
|
.multilineTextAlignment(.center)
|
|
Text(text.hint)
|
|
.font(.mealMoodCaption)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
PrimaryButton(
|
|
title: String(localized: "premium_retry_products"),
|
|
action: { Task { await storeManager.loadProducts() } },
|
|
isEnabled: !storeManager.isLoadingProducts
|
|
)
|
|
}
|
|
.padding(16)
|
|
.background(Color.mealMoodSurface)
|
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
|
}
|
|
|
|
private func purchase(_ product: Product) async {
|
|
AnalyticsService.logPremiumUpgradeTapped(source: source)
|
|
let result = await storeManager.purchase(product)
|
|
switch result {
|
|
case .success:
|
|
settings.isPremium = true
|
|
didConvert = true
|
|
if storeManager.hasTrialAvailable {
|
|
AnalyticsService.logFreeTrialStarted(plan: product.id)
|
|
}
|
|
try? context.save()
|
|
ReviewPromptService.shared.requestAfterPurchase()
|
|
case .pending:
|
|
purchaseStatusMessageKey = "premium_purchase_pending"
|
|
case .cancelled:
|
|
purchaseStatusMessageKey = "premium_purchase_cancelled"
|
|
case .failed:
|
|
purchaseStatusMessageKey = "premium_purchase_failed"
|
|
}
|
|
}
|
|
|
|
private func restorePurchases() async {
|
|
await storeManager.restorePurchases()
|
|
settings.isPremium = storeManager.isPremium
|
|
try? context.save()
|
|
}
|
|
|
|
private func fallbackContent(for state: StoreManager.ProductLoadState) -> (price: String, hint: String) {
|
|
switch state {
|
|
case .timedOut:
|
|
return (
|
|
String(localized: "premium_loading_timeout"),
|
|
String(localized: "premium_loading_timeout_hint")
|
|
)
|
|
case .notFound:
|
|
return (
|
|
String(localized: "premium_products_not_found"),
|
|
String(localized: "premium_products_not_found_hint")
|
|
)
|
|
case .failed:
|
|
return (
|
|
String(localized: "premium_loading_failed"),
|
|
String(localized: "premium_loading_failed_hint")
|
|
)
|
|
case .idle, .loading, .loaded:
|
|
return (
|
|
String(localized: "premium_loading_products"),
|
|
String(localized: "premium_loading_products_hint")
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct BenefitItem: View {
|
|
let text: String
|
|
|
|
var body: some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(.mealMoodSuccess)
|
|
.font(.system(size: 18))
|
|
Text(text)
|
|
.font(.mealMoodBody)
|
|
.foregroundColor(.mealMoodTextPrimary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct PlanCard: View {
|
|
let title: String
|
|
let price: String
|
|
let caption: String
|
|
var buttonTitle: String? = nil
|
|
var isLoading: Bool = false
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(spacing: 12) {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(title)
|
|
.font(.mealMoodH3)
|
|
.foregroundColor(.mealMoodTextPrimary)
|
|
Text(price)
|
|
.font(.mealMoodBody)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
Text(caption)
|
|
.font(.mealMoodCaption)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
}
|
|
Spacer()
|
|
}
|
|
|
|
PrimaryButton(
|
|
title: isLoading
|
|
? String(localized: "premium_processing")
|
|
: (buttonTitle ?? String(localized: "premium_subscribe")),
|
|
action: action,
|
|
isEnabled: !isLoading
|
|
)
|
|
}
|
|
.padding(16)
|
|
.background(Color.mealMoodSurface)
|
|
.cornerRadius(16)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
|
|
)
|
|
}
|
|
}
|