1.0.1: add review funnel, trial onboarding, and app diagnostics
This commit is contained in:
@@ -4,6 +4,7 @@ import SwiftData
|
||||
struct HomeView: View {
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.modelContext) private var context
|
||||
@Environment(\.openURL) private var openURL
|
||||
@Query private var dishes: [Dish]
|
||||
@Query private var tags: [Tag]
|
||||
@Query(sort: \WeekPlan.weekStartDate, order: .forward) private var weekPlans: [WeekPlan]
|
||||
@@ -18,6 +19,8 @@ struct HomeView: View {
|
||||
@State private var showWeekPicker: Bool = false
|
||||
@State private var weekPickerDate: Date = Date()
|
||||
@State private var showCopyPreviousConfirm: Bool = false
|
||||
@State private var showReviewSentimentPrompt: Bool = false
|
||||
@State private var showReviewSupportPrompt: Bool = false
|
||||
|
||||
private var settings: AppSettings? { allSettings.first }
|
||||
|
||||
@@ -248,6 +251,28 @@ struct HomeView: View {
|
||||
secondaryButton: .cancel(Text("reset_cancel"))
|
||||
)
|
||||
}
|
||||
.alert("review_funnel_title", isPresented: $showReviewSentimentPrompt) {
|
||||
Button("review_funnel_positive") {
|
||||
ReviewPromptService.shared.requestReview()
|
||||
ReviewPromptService.shared.markReviewCompleted()
|
||||
}
|
||||
Button("review_funnel_negative", role: .destructive) {
|
||||
showReviewSupportPrompt = true
|
||||
}
|
||||
Button("reset_cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("review_funnel_message")
|
||||
}
|
||||
.alert("review_feedback_title", isPresented: $showReviewSupportPrompt) {
|
||||
Button("review_feedback_contact") {
|
||||
if let url = URL(string: "mailto:support@mealmood.app?subject=MealMood%20Feedback") {
|
||||
openURL(url)
|
||||
}
|
||||
}
|
||||
Button("reset_cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("review_feedback_message")
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showDishForm) {
|
||||
DishFormView()
|
||||
}
|
||||
@@ -614,7 +639,9 @@ struct HomeView: View {
|
||||
let descriptor = FetchDescriptor<WeekPlan>()
|
||||
guard let plans = try? context.fetch(descriptor) else { return }
|
||||
let completedWeeks = plans.filter { !$0.slots.isEmpty && $0.slots.allSatisfy { $0.dishId != nil } }.count
|
||||
ReviewPromptService.shared.considerPromptAfterWeekCompletion(completedWeeks: completedWeeks)
|
||||
guard ReviewPromptService.shared.shouldShowFunnelAfterWeekCompletion(completedWeeks: completedWeeks) else { return }
|
||||
ReviewPromptService.shared.markFunnelShown(completedWeeks: completedWeeks)
|
||||
showReviewSentimentPrompt = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import StoreKit
|
||||
|
||||
struct OnboardingView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@@ -29,17 +30,22 @@ struct OnboardingView: View {
|
||||
WelcomeStepView(onNext: { viewModel.nextStep() })
|
||||
.tag(0)
|
||||
|
||||
TrialStepView(
|
||||
onContinue: { viewModel.nextStep() }
|
||||
)
|
||||
.tag(1)
|
||||
|
||||
MealWindowsStepView(
|
||||
selection: $viewModel.selectedMealWindows,
|
||||
onNext: { viewModel.nextStep() }
|
||||
)
|
||||
.tag(1)
|
||||
.tag(2)
|
||||
|
||||
WeekendsStepView(
|
||||
includeWeekends: $viewModel.includeWeekends,
|
||||
onNext: { viewModel.nextStep() }
|
||||
)
|
||||
.tag(2)
|
||||
.tag(3)
|
||||
|
||||
CalendarStepView(
|
||||
iCloudSyncEnabled: $viewModel.syncICloud,
|
||||
@@ -53,7 +59,7 @@ struct OnboardingView: View {
|
||||
onNext: { viewModel.nextStep() },
|
||||
onSkip: { viewModel.nextStep() }
|
||||
)
|
||||
.tag(3)
|
||||
.tag(4)
|
||||
|
||||
FirstDishesStepView(
|
||||
viewModel: viewModel,
|
||||
@@ -64,7 +70,7 @@ struct OnboardingView: View {
|
||||
}
|
||||
}
|
||||
)
|
||||
.tag(4)
|
||||
.tag(5)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.animation(.easeInOut(duration: 0.3), value: viewModel.currentStep)
|
||||
@@ -107,3 +113,133 @@ struct OnboardingView: View {
|
||||
return !plans.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
private struct TrialStepView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@StateObject private var storeManager = StoreManager()
|
||||
@State private var purchaseStatusMessageKey: String?
|
||||
|
||||
let onContinue: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Text("onboarding_trial_title")
|
||||
.font(.mealMoodH2)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("onboarding_trial_subtitle")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label("premium_no_ads", systemImage: "checkmark.circle.fill")
|
||||
Label("premium_unlimited_dishes", systemImage: "checkmark.circle.fill")
|
||||
Label("premium_advanced_rules", systemImage: "checkmark.circle.fill")
|
||||
}
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(14)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
if let monthly = storeManager.monthlyProduct {
|
||||
VStack(spacing: 10) {
|
||||
Text(
|
||||
String(
|
||||
format: String(localized: "onboarding_trial_price_format"),
|
||||
monthly.displayPrice
|
||||
)
|
||||
)
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
|
||||
PrimaryButton(
|
||||
title: storeManager.isLoading
|
||||
? String(localized: "premium_processing")
|
||||
: String(localized: "onboarding_trial_cta"),
|
||||
action: { Task { await startTrial(with: monthly) } },
|
||||
isEnabled: !storeManager.isLoading,
|
||||
localizeTitle: false
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
} else {
|
||||
let fallbackText = fallbackContent(for: storeManager.productLoadState)
|
||||
VStack(spacing: 8) {
|
||||
Text(fallbackText)
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
SecondaryButton(
|
||||
title: String(localized: "premium_retry_products"),
|
||||
action: { Task { await storeManager.loadProducts() } },
|
||||
localizeTitle: false
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
SecondaryButton(
|
||||
title: String(localized: "onboarding_trial_skip"),
|
||||
action: onContinue,
|
||||
localizeTitle: false
|
||||
)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
.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 startTrial(with product: Product) async {
|
||||
let result = await storeManager.purchase(product)
|
||||
switch result {
|
||||
case .success:
|
||||
let descriptor = FetchDescriptor<AppSettings>()
|
||||
let settings = (try? context.fetch(descriptor))?.first ?? {
|
||||
let created = AppSettings()
|
||||
context.insert(created)
|
||||
return created
|
||||
}()
|
||||
settings.isPremium = true
|
||||
try? context.save()
|
||||
onContinue()
|
||||
case .pending:
|
||||
purchaseStatusMessageKey = "premium_purchase_pending"
|
||||
case .cancelled:
|
||||
purchaseStatusMessageKey = "premium_purchase_cancelled"
|
||||
case .failed:
|
||||
purchaseStatusMessageKey = "premium_purchase_failed"
|
||||
}
|
||||
}
|
||||
|
||||
private func fallbackContent(for state: StoreManager.ProductLoadState) -> String {
|
||||
switch state {
|
||||
case .timedOut:
|
||||
return String(localized: "premium_loading_timeout_hint")
|
||||
case .notFound:
|
||||
return String(localized: "premium_products_not_found_hint")
|
||||
case .failed:
|
||||
return String(localized: "premium_loading_failed_hint")
|
||||
case .idle, .loading, .loaded:
|
||||
return String(localized: "premium_loading_products_hint")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +194,20 @@ struct SettingsView: View {
|
||||
Link(destination: URL(string: "mailto:support@mealmood.app")!) {
|
||||
Label("settings_support", systemImage: "envelope")
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("settings_app_version")
|
||||
Spacer()
|
||||
Text(viewModel.appVersion)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("settings_app_build")
|
||||
Spacer()
|
||||
Text(viewModel.appBuild)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
} header: {
|
||||
Label("settings_about", systemImage: "info.circle")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user