diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index aee7272..9eb150e 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -149,6 +149,8 @@ "settings_website" = "Website"; "settings_support" = "Support email"; "settings_rate_app" = "Rate MealMood"; +"settings_app_version" = "Version"; +"settings_app_build" = "Build"; "settings_danger_zone" = "Danger zone"; "settings_reset_all_data" = "Reset all data"; "settings_reset_all_data_title" = "Reset all app data?"; @@ -215,6 +217,22 @@ "first_dishes_suggestion_added" = "Added"; "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 */ "notification_planning_title" = "Plan your next week"; "notification_planning_body" = "Your new week starts tomorrow and it is still not planned."; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index beb1c91..fa713bf 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -149,6 +149,8 @@ "settings_website" = "Web"; "settings_support" = "Correo de soporte"; "settings_rate_app" = "Valorar MealMood"; +"settings_app_version" = "Versión"; +"settings_app_build" = "Build"; "settings_danger_zone" = "Zona de peligro"; "settings_reset_all_data" = "Resetear todos los datos"; "settings_reset_all_data_title" = "¿Resetear todos los datos de la app?"; @@ -215,6 +217,22 @@ "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"; +/* 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 */ "notification_planning_title" = "Planifica tu próxima semana"; "notification_planning_body" = "Mañana empieza la semana y aún no está planificada."; diff --git a/MealMood/Services/ReviewPromptService.swift b/MealMood/Services/ReviewPromptService.swift index acfd34d..9566031 100644 --- a/MealMood/Services/ReviewPromptService.swift +++ b/MealMood/Services/ReviewPromptService.swift @@ -5,35 +5,39 @@ import StoreKit final class ReviewPromptService { static let shared = ReviewPromptService() - private let milestones = [1, 2, 4, 8] - private let minimumDaysBetweenPrompts: Double = 30 - private let promptedMilestoneKey = "review_prompted_milestone" - private let lastPromptDateKey = "review_prompt_last_date" + private let reviewCompletedKey = "review_funnel_completed" + private let lastPromptedCompletedWeeksKey = "review_funnel_last_prompted_completed_weeks" + private let minimumCompletedWeeksToStart = 2 + private let promptIntervalWeeks = 2 private init() {} - func considerPromptAfterWeekCompletion(completedWeeks: Int) { - guard let milestone = milestones.first(where: { completedWeeks >= $0 }) else { return } - let alreadyPrompted = UserDefaults.standard.integer(forKey: promptedMilestoneKey) - guard milestone > alreadyPrompted else { return } - guard canPromptNow() else { return } + func shouldShowFunnelAfterWeekCompletion(completedWeeks: Int) -> Bool { + guard !hasCompletedReviewFlow else { return false } + guard completedWeeks >= minimumCompletedWeeksToStart else { return false } + guard completedWeeks % promptIntervalWeeks == 0 else { return false } - requestReview() - UserDefaults.standard.set(milestone, forKey: promptedMilestoneKey) - UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: lastPromptDateKey) + let lastPrompted = UserDefaults.standard.integer(forKey: lastPromptedCompletedWeeksKey) + return completedWeeks > lastPrompted + } + + func markFunnelShown(completedWeeks: Int) { + UserDefaults.standard.set(completedWeeks, forKey: lastPromptedCompletedWeeksKey) + } + + func markReviewCompleted() { + UserDefaults.standard.set(true, forKey: reviewCompletedKey) } func requestFromSettings() { requestReview() } - private func canPromptNow() -> Bool { - let lastPrompt = UserDefaults.standard.double(forKey: lastPromptDateKey) - guard lastPrompt > 0 else { return true } - return Date().timeIntervalSince1970 - lastPrompt >= minimumDaysBetweenPrompts * 24 * 60 * 60 + var hasCompletedReviewFlow: Bool { + UserDefaults.standard.bool(forKey: reviewCompletedKey) } - private func requestReview() { + func requestReview() { guard let scene = UIApplication.shared.connectedScenes .compactMap({ $0 as? UIWindowScene }) .first(where: { $0.activationState == .foregroundActive }) else { return } diff --git a/MealMood/ViewModels/OnboardingViewModel.swift b/MealMood/ViewModels/OnboardingViewModel.swift index 66499b2..0f4d3a9 100644 --- a/MealMood/ViewModels/OnboardingViewModel.swift +++ b/MealMood/ViewModels/OnboardingViewModel.swift @@ -24,15 +24,16 @@ final class OnboardingViewModel: ObservableObject { @Published var addedDishes: [(name: String, tagIds: [UUID])] = [] let maxOnboardingDishes = 10 - let totalSteps = 5 + let totalSteps = 6 var canContinue: Bool { switch currentStep { case 0: return true // Welcome - case 1: return true // Meal windows (always has selection) - case 2: return true // Weekends (toggle) - case 3: return true // Calendar (optional) - case 4: return addedDishes.count >= 2 // Need at least 2 dishes + case 1: return true // Trial + case 2: return true // Meal windows (always has selection) + case 3: return true // Weekends (toggle) + case 4: return true // Calendar (optional) + case 5: return addedDishes.count >= 2 // Need at least 2 dishes default: return false } } diff --git a/MealMood/ViewModels/SettingsViewModel.swift b/MealMood/ViewModels/SettingsViewModel.swift index 7095ac5..001f31c 100644 --- a/MealMood/ViewModels/SettingsViewModel.swift +++ b/MealMood/ViewModels/SettingsViewModel.swift @@ -9,6 +9,14 @@ final class SettingsViewModel: ObservableObject { @Published var showToast: Bool = false @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() { availableCalendars = CalendarService.shared.availableCalendars() } diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index 9bc9392..7192c7e 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -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() 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 } } diff --git a/MealMood/Views/Onboarding/OnboardingView.swift b/MealMood/Views/Onboarding/OnboardingView.swift index bab6574..9fcfe95 100644 --- a/MealMood/Views/Onboarding/OnboardingView.swift +++ b/MealMood/Views/Onboarding/OnboardingView.swift @@ -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() + 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") + } + } +} diff --git a/MealMood/Views/Settings/SettingsView.swift b/MealMood/Views/Settings/SettingsView.swift index 7bb9447..371de2f 100644 --- a/MealMood/Views/Settings/SettingsView.swift +++ b/MealMood/Views/Settings/SettingsView.swift @@ -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") }