Files
FamilyMealPlanner/MealMood/Views/Premium/PremiumView.swift
T
alexandrev-tibco 47daab9e51 platos: paywall inmediato al llegar al limite y orden alfabetico
- attemptAddDish/attemptCreateDish: el limite gratuito se comprueba en el
  tap de anadir plato (DishListView y los 3 puntos de Home) y muestra el
  DishLimitModal -> paywall directamente; antes solo se comprobaba al
  guardar el formulario y el usuario podia rellenarlo entero para nada
- DishLimitModal compartido (era private de DishFormView) y
  PaywallPresentation promovido a PremiumView.swift; DishListView usa
  sheet(item:) tambien para el banner (source dish_counter vs dish_limit)
- Boton en la toolbar de Mis Platos que alterna orden alfabetico
  (persistido en dish_list_alphabetical_sort); borrado por swipe corregido
  para usar la lista mostrada

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
2026-09-01 11:18:34 +02:00

466 lines
18 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)
.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)
)
}
}