1.0.1: add review funnel, trial onboarding, and app diagnostics
This commit is contained in:
@@ -149,6 +149,8 @@
|
|||||||
"settings_website" = "Website";
|
"settings_website" = "Website";
|
||||||
"settings_support" = "Support email";
|
"settings_support" = "Support email";
|
||||||
"settings_rate_app" = "Rate MealMood";
|
"settings_rate_app" = "Rate MealMood";
|
||||||
|
"settings_app_version" = "Version";
|
||||||
|
"settings_app_build" = "Build";
|
||||||
"settings_danger_zone" = "Danger zone";
|
"settings_danger_zone" = "Danger zone";
|
||||||
"settings_reset_all_data" = "Reset all data";
|
"settings_reset_all_data" = "Reset all data";
|
||||||
"settings_reset_all_data_title" = "Reset all app data?";
|
"settings_reset_all_data_title" = "Reset all app data?";
|
||||||
@@ -215,6 +217,22 @@
|
|||||||
"first_dishes_suggestion_added" = "Added";
|
"first_dishes_suggestion_added" = "Added";
|
||||||
"first_dishes_no_tags" = "Default tags are loading, please retry in a moment";
|
"first_dishes_no_tags" = "Default tags are loading, please retry in a moment";
|
||||||
|
|
||||||
|
/* Onboarding trial */
|
||||||
|
"onboarding_trial_title" = "Try Premium free for 7 days";
|
||||||
|
"onboarding_trial_subtitle" = "Unlock all premium features now. You can cancel anytime before billing starts.";
|
||||||
|
"onboarding_trial_cta" = "Start 7-day free trial";
|
||||||
|
"onboarding_trial_skip" = "Continue with free plan";
|
||||||
|
"onboarding_trial_price_format" = "Then %@ / month";
|
||||||
|
|
||||||
|
/* Review funnel */
|
||||||
|
"review_funnel_title" = "How is MealMood going?";
|
||||||
|
"review_funnel_message" = "Your opinion helps us improve MealMood.";
|
||||||
|
"review_funnel_positive" = "I like it";
|
||||||
|
"review_funnel_negative" = "Needs improvement";
|
||||||
|
"review_feedback_title" = "Tell us what to improve";
|
||||||
|
"review_feedback_message" = "Send us your feedback and we'll use it to improve the app.";
|
||||||
|
"review_feedback_contact" = "Send feedback";
|
||||||
|
|
||||||
/* Notifications */
|
/* Notifications */
|
||||||
"notification_planning_title" = "Plan your next week";
|
"notification_planning_title" = "Plan your next week";
|
||||||
"notification_planning_body" = "Your new week starts tomorrow and it is still not planned.";
|
"notification_planning_body" = "Your new week starts tomorrow and it is still not planned.";
|
||||||
|
|||||||
@@ -149,6 +149,8 @@
|
|||||||
"settings_website" = "Web";
|
"settings_website" = "Web";
|
||||||
"settings_support" = "Correo de soporte";
|
"settings_support" = "Correo de soporte";
|
||||||
"settings_rate_app" = "Valorar MealMood";
|
"settings_rate_app" = "Valorar MealMood";
|
||||||
|
"settings_app_version" = "Versión";
|
||||||
|
"settings_app_build" = "Build";
|
||||||
"settings_danger_zone" = "Zona de peligro";
|
"settings_danger_zone" = "Zona de peligro";
|
||||||
"settings_reset_all_data" = "Resetear todos los datos";
|
"settings_reset_all_data" = "Resetear todos los datos";
|
||||||
"settings_reset_all_data_title" = "¿Resetear todos los datos de la app?";
|
"settings_reset_all_data_title" = "¿Resetear todos los datos de la app?";
|
||||||
@@ -215,6 +217,22 @@
|
|||||||
"first_dishes_suggestion_added" = "Añadido";
|
"first_dishes_suggestion_added" = "Añadido";
|
||||||
"first_dishes_no_tags" = "Las etiquetas por defecto se están cargando, inténtalo de nuevo en un momento";
|
"first_dishes_no_tags" = "Las etiquetas por defecto se están cargando, inténtalo de nuevo en un momento";
|
||||||
|
|
||||||
|
/* Onboarding trial */
|
||||||
|
"onboarding_trial_title" = "Prueba Premium gratis durante 7 días";
|
||||||
|
"onboarding_trial_subtitle" = "Desbloquea todas las funciones premium ahora. Puedes cancelar antes del primer cobro.";
|
||||||
|
"onboarding_trial_cta" = "Iniciar prueba gratuita de 7 días";
|
||||||
|
"onboarding_trial_skip" = "Continuar con plan gratuito";
|
||||||
|
"onboarding_trial_price_format" = "Después %@ / mes";
|
||||||
|
|
||||||
|
/* Review funnel */
|
||||||
|
"review_funnel_title" = "¿Qué te está pareciendo MealMood?";
|
||||||
|
"review_funnel_message" = "Tu opinión nos ayuda a mejorar MealMood.";
|
||||||
|
"review_funnel_positive" = "Me gusta";
|
||||||
|
"review_funnel_negative" = "Se puede mejorar";
|
||||||
|
"review_feedback_title" = "Cuéntanos qué mejorar";
|
||||||
|
"review_feedback_message" = "Envíanos tu feedback y lo usaremos para mejorar la app.";
|
||||||
|
"review_feedback_contact" = "Enviar feedback";
|
||||||
|
|
||||||
/* Notifications */
|
/* Notifications */
|
||||||
"notification_planning_title" = "Planifica tu próxima semana";
|
"notification_planning_title" = "Planifica tu próxima semana";
|
||||||
"notification_planning_body" = "Mañana empieza la semana y aún no está planificada.";
|
"notification_planning_body" = "Mañana empieza la semana y aún no está planificada.";
|
||||||
|
|||||||
@@ -5,35 +5,39 @@ import StoreKit
|
|||||||
final class ReviewPromptService {
|
final class ReviewPromptService {
|
||||||
static let shared = ReviewPromptService()
|
static let shared = ReviewPromptService()
|
||||||
|
|
||||||
private let milestones = [1, 2, 4, 8]
|
private let reviewCompletedKey = "review_funnel_completed"
|
||||||
private let minimumDaysBetweenPrompts: Double = 30
|
private let lastPromptedCompletedWeeksKey = "review_funnel_last_prompted_completed_weeks"
|
||||||
private let promptedMilestoneKey = "review_prompted_milestone"
|
private let minimumCompletedWeeksToStart = 2
|
||||||
private let lastPromptDateKey = "review_prompt_last_date"
|
private let promptIntervalWeeks = 2
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
func considerPromptAfterWeekCompletion(completedWeeks: Int) {
|
func shouldShowFunnelAfterWeekCompletion(completedWeeks: Int) -> Bool {
|
||||||
guard let milestone = milestones.first(where: { completedWeeks >= $0 }) else { return }
|
guard !hasCompletedReviewFlow else { return false }
|
||||||
let alreadyPrompted = UserDefaults.standard.integer(forKey: promptedMilestoneKey)
|
guard completedWeeks >= minimumCompletedWeeksToStart else { return false }
|
||||||
guard milestone > alreadyPrompted else { return }
|
guard completedWeeks % promptIntervalWeeks == 0 else { return false }
|
||||||
guard canPromptNow() else { return }
|
|
||||||
|
|
||||||
requestReview()
|
let lastPrompted = UserDefaults.standard.integer(forKey: lastPromptedCompletedWeeksKey)
|
||||||
UserDefaults.standard.set(milestone, forKey: promptedMilestoneKey)
|
return completedWeeks > lastPrompted
|
||||||
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: lastPromptDateKey)
|
}
|
||||||
|
|
||||||
|
func markFunnelShown(completedWeeks: Int) {
|
||||||
|
UserDefaults.standard.set(completedWeeks, forKey: lastPromptedCompletedWeeksKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markReviewCompleted() {
|
||||||
|
UserDefaults.standard.set(true, forKey: reviewCompletedKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestFromSettings() {
|
func requestFromSettings() {
|
||||||
requestReview()
|
requestReview()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func canPromptNow() -> Bool {
|
var hasCompletedReviewFlow: Bool {
|
||||||
let lastPrompt = UserDefaults.standard.double(forKey: lastPromptDateKey)
|
UserDefaults.standard.bool(forKey: reviewCompletedKey)
|
||||||
guard lastPrompt > 0 else { return true }
|
|
||||||
return Date().timeIntervalSince1970 - lastPrompt >= minimumDaysBetweenPrompts * 24 * 60 * 60
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func requestReview() {
|
func requestReview() {
|
||||||
guard let scene = UIApplication.shared.connectedScenes
|
guard let scene = UIApplication.shared.connectedScenes
|
||||||
.compactMap({ $0 as? UIWindowScene })
|
.compactMap({ $0 as? UIWindowScene })
|
||||||
.first(where: { $0.activationState == .foregroundActive }) else { return }
|
.first(where: { $0.activationState == .foregroundActive }) else { return }
|
||||||
|
|||||||
@@ -24,15 +24,16 @@ final class OnboardingViewModel: ObservableObject {
|
|||||||
@Published var addedDishes: [(name: String, tagIds: [UUID])] = []
|
@Published var addedDishes: [(name: String, tagIds: [UUID])] = []
|
||||||
let maxOnboardingDishes = 10
|
let maxOnboardingDishes = 10
|
||||||
|
|
||||||
let totalSteps = 5
|
let totalSteps = 6
|
||||||
|
|
||||||
var canContinue: Bool {
|
var canContinue: Bool {
|
||||||
switch currentStep {
|
switch currentStep {
|
||||||
case 0: return true // Welcome
|
case 0: return true // Welcome
|
||||||
case 1: return true // Meal windows (always has selection)
|
case 1: return true // Trial
|
||||||
case 2: return true // Weekends (toggle)
|
case 2: return true // Meal windows (always has selection)
|
||||||
case 3: return true // Calendar (optional)
|
case 3: return true // Weekends (toggle)
|
||||||
case 4: return addedDishes.count >= 2 // Need at least 2 dishes
|
case 4: return true // Calendar (optional)
|
||||||
|
case 5: return addedDishes.count >= 2 // Need at least 2 dishes
|
||||||
default: return false
|
default: return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ final class SettingsViewModel: ObservableObject {
|
|||||||
@Published var showToast: Bool = false
|
@Published var showToast: Bool = false
|
||||||
@Published var toastMessage: String = ""
|
@Published var toastMessage: String = ""
|
||||||
|
|
||||||
|
var appVersion: String {
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
var appBuild: String {
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "-"
|
||||||
|
}
|
||||||
|
|
||||||
func loadCalendars() {
|
func loadCalendars() {
|
||||||
availableCalendars = CalendarService.shared.availableCalendars()
|
availableCalendars = CalendarService.shared.availableCalendars()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import SwiftData
|
|||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
@Environment(\.modelContext) private var context
|
@Environment(\.modelContext) private var context
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
@Query private var dishes: [Dish]
|
@Query private var dishes: [Dish]
|
||||||
@Query private var tags: [Tag]
|
@Query private var tags: [Tag]
|
||||||
@Query(sort: \WeekPlan.weekStartDate, order: .forward) private var weekPlans: [WeekPlan]
|
@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 showWeekPicker: Bool = false
|
||||||
@State private var weekPickerDate: Date = Date()
|
@State private var weekPickerDate: Date = Date()
|
||||||
@State private var showCopyPreviousConfirm: Bool = false
|
@State private var showCopyPreviousConfirm: Bool = false
|
||||||
|
@State private var showReviewSentimentPrompt: Bool = false
|
||||||
|
@State private var showReviewSupportPrompt: Bool = false
|
||||||
|
|
||||||
private var settings: AppSettings? { allSettings.first }
|
private var settings: AppSettings? { allSettings.first }
|
||||||
|
|
||||||
@@ -248,6 +251,28 @@ struct HomeView: View {
|
|||||||
secondaryButton: .cancel(Text("reset_cancel"))
|
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) {
|
.sheet(isPresented: $viewModel.showDishForm) {
|
||||||
DishFormView()
|
DishFormView()
|
||||||
}
|
}
|
||||||
@@ -614,7 +639,9 @@ struct HomeView: View {
|
|||||||
let descriptor = FetchDescriptor<WeekPlan>()
|
let descriptor = FetchDescriptor<WeekPlan>()
|
||||||
guard let plans = try? context.fetch(descriptor) else { return }
|
guard let plans = try? context.fetch(descriptor) else { return }
|
||||||
let completedWeeks = plans.filter { !$0.slots.isEmpty && $0.slots.allSatisfy { $0.dishId != nil } }.count
|
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 SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import StoreKit
|
||||||
|
|
||||||
struct OnboardingView: View {
|
struct OnboardingView: View {
|
||||||
@Environment(\.modelContext) private var context
|
@Environment(\.modelContext) private var context
|
||||||
@@ -29,17 +30,22 @@ struct OnboardingView: View {
|
|||||||
WelcomeStepView(onNext: { viewModel.nextStep() })
|
WelcomeStepView(onNext: { viewModel.nextStep() })
|
||||||
.tag(0)
|
.tag(0)
|
||||||
|
|
||||||
|
TrialStepView(
|
||||||
|
onContinue: { viewModel.nextStep() }
|
||||||
|
)
|
||||||
|
.tag(1)
|
||||||
|
|
||||||
MealWindowsStepView(
|
MealWindowsStepView(
|
||||||
selection: $viewModel.selectedMealWindows,
|
selection: $viewModel.selectedMealWindows,
|
||||||
onNext: { viewModel.nextStep() }
|
onNext: { viewModel.nextStep() }
|
||||||
)
|
)
|
||||||
.tag(1)
|
.tag(2)
|
||||||
|
|
||||||
WeekendsStepView(
|
WeekendsStepView(
|
||||||
includeWeekends: $viewModel.includeWeekends,
|
includeWeekends: $viewModel.includeWeekends,
|
||||||
onNext: { viewModel.nextStep() }
|
onNext: { viewModel.nextStep() }
|
||||||
)
|
)
|
||||||
.tag(2)
|
.tag(3)
|
||||||
|
|
||||||
CalendarStepView(
|
CalendarStepView(
|
||||||
iCloudSyncEnabled: $viewModel.syncICloud,
|
iCloudSyncEnabled: $viewModel.syncICloud,
|
||||||
@@ -53,7 +59,7 @@ struct OnboardingView: View {
|
|||||||
onNext: { viewModel.nextStep() },
|
onNext: { viewModel.nextStep() },
|
||||||
onSkip: { viewModel.nextStep() }
|
onSkip: { viewModel.nextStep() }
|
||||||
)
|
)
|
||||||
.tag(3)
|
.tag(4)
|
||||||
|
|
||||||
FirstDishesStepView(
|
FirstDishesStepView(
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
@@ -64,7 +70,7 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.tag(4)
|
.tag(5)
|
||||||
}
|
}
|
||||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
.animation(.easeInOut(duration: 0.3), value: viewModel.currentStep)
|
.animation(.easeInOut(duration: 0.3), value: viewModel.currentStep)
|
||||||
@@ -107,3 +113,133 @@ struct OnboardingView: View {
|
|||||||
return !plans.isEmpty
|
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")!) {
|
Link(destination: URL(string: "mailto:support@mealmood.app")!) {
|
||||||
Label("settings_support", systemImage: "envelope")
|
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: {
|
} header: {
|
||||||
Label("settings_about", systemImage: "info.circle")
|
Label("settings_about", systemImage: "info.circle")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user