110807d239
- Eating out: tap any empty slot → "Mark as eating out"; shows teal indicator, skipped by auto-assign, counts as complete, tap again to unmark - Rule violations panel: access via wand long-press context menu when conflicts exist; shows all isRuleOverridden slots with Fix (clear) or Ignore (acknowledge) actions - Firebase Crashlytics integrated: CrashlyticsService + dSYM upload build phase, isPremium property tracked per session - PremiumSyncService: extracted premium state machine, StoreKit Transaction.updates listener, isPremium no longer synced via iCloud to avoid stale state - StoreManager: analytics on purchase/restore, bundle ID fallback for product ID lookup - Search bar contrast bug fixed: TextField now has explicit foreground color for dark mode - WelcomeStepView: redesigned onboarding welcome screen with week preview - Version bump: 1.0.5 build 22 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
254 lines
10 KiB
Swift
254 lines
10 KiB
Swift
import SwiftUI
|
|
import StoreKit
|
|
|
|
struct PremiumView: View {
|
|
@Environment(\.modelContext) private var context
|
|
@StateObject private var storeManager = StoreManager()
|
|
@State private var purchaseStatusMessageKey: String?
|
|
|
|
let settings: AppSettings
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.mealMoodBackground.ignoresSafeArea()
|
|
|
|
ScrollView {
|
|
VStack(spacing: 24) {
|
|
VStack(spacing: 16) {
|
|
AppIconPlaceholder(size: 84)
|
|
|
|
Text("premium_title")
|
|
.font(.mealMoodH1)
|
|
.foregroundColor(.mealMoodTextPrimary)
|
|
|
|
Text("premium_subtitle")
|
|
.font(.mealMoodBody)
|
|
.foregroundColor(.mealMoodTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.padding(.top, 28)
|
|
.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(20)
|
|
.background(Color.mealMoodSurface)
|
|
.cornerRadius(16)
|
|
.padding(.horizontal, 24)
|
|
|
|
if settings.isPremium {
|
|
Label("premium_active", systemImage: "checkmark.seal.fill")
|
|
.font(.mealMoodBodyBold)
|
|
.foregroundColor(.mealMoodSuccess)
|
|
.padding(.horizontal, 24)
|
|
} else if let monthly = storeManager.monthlyProduct {
|
|
PlanCard(
|
|
title: String(localized: "premium_monthly"),
|
|
price: monthly.displayPrice + " / " + String(localized: "premium_month"),
|
|
caption: String(localized: "premium_price_note"),
|
|
isLoading: storeManager.isLoading
|
|
) {
|
|
Task { await purchase(monthly) }
|
|
}
|
|
.padding(.horizontal, 24)
|
|
} else {
|
|
let fallbackText = fallbackContent(for: storeManager.productLoadState)
|
|
|
|
PlanCard(
|
|
title: String(localized: "premium_monthly"),
|
|
price: fallbackText.price,
|
|
caption: fallbackText.hint,
|
|
buttonTitle: String(localized: "premium_retry_products"),
|
|
isLoading: storeManager.isLoadingProducts
|
|
) {
|
|
Task { await storeManager.loadProducts() }
|
|
}
|
|
.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)
|
|
|
|
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()
|
|
}
|
|
.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 ?? ""))
|
|
}
|
|
}
|
|
|
|
private func purchase(_ product: Product) async {
|
|
AnalyticsService.logPremiumUpgradeTapped(source: "premium_view")
|
|
let result = await storeManager.purchase(product)
|
|
switch result {
|
|
case .success:
|
|
settings.isPremium = true
|
|
try? context.save()
|
|
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)
|
|
)
|
|
}
|
|
}
|