import SwiftUI import StoreKit 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) ) } }