import SwiftUI import SwiftData @MainActor final class OnboardingViewModel: ObservableObject { static let pendingAutoAssignPromptKey = "onboarding_pending_auto_assign_prompt" static let pendingPremiumPromptKey = "onboarding_pending_premium_prompt" static let pendingAutoFillOnLaunchKey = "onboarding_pending_auto_fill_on_launch" @Published var currentStep: Int = 0 @Published var selectedMealTypes: Set = [.dinner] @Published var includeWeekends: Bool = true @Published var syncICloud: Bool = true @Published var syncCalendar: Bool = false @Published var selectedCalendarId: String? @Published var lunchTime: Date = { var c = DateComponents(); c.hour = 14; c.minute = 0 return Calendar.current.date(from: c) ?? Date() }() @Published var dinnerTime: Date = { var c = DateComponents(); c.hour = 21; c.minute = 0 return Calendar.current.date(from: c) ?? Date() }() // First dishes @Published var newDishName: String = "" @Published var newDishTags: Set = [] @Published var addedDishes: [(name: String, tagIds: [UUID])] = [] let maxOnboardingDishes = 10 let totalSteps = 7 /// Slots filled by the in-onboarding auto-fill (aha moment step). @Published var autoFilledCount: Int = 0 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 true // Dishes are optional case 5: return true // Week ready (aha moment + reminder opt-in) case 6: return true // Paywall (always skippable) default: return false } } var canAddDish: Bool { !newDishName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && canAddMoreDishes } var canAddMoreDishes: Bool { addedDishes.count < maxOnboardingDishes } func addDish(defaultTagId: UUID? = nil) { guard canAddMoreDishes else { return } guard canAddDish else { return } let normalizedName = newDishName.trimmingCharacters(in: .whitespacesAndNewlines) guard !addedDishes.contains(where: { $0.name.lowercased() == normalizedName.lowercased() }) else { return } let tagIds: [UUID] if !newDishTags.isEmpty { tagIds = Array(newDishTags) } else if let defaultTagId { tagIds = [defaultTagId] } else { tagIds = [] } addedDishes.append((name: normalizedName, tagIds: tagIds)) newDishName = "" newDishTags = [] } func removeDish(at index: Int) { guard addedDishes.indices.contains(index) else { return } addedDishes.remove(at: index) } func addSuggestedDish(name: String, preferredTagNames: [String], availableTags: [Tag]) { guard canAddMoreDishes else { return } let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedName.isEmpty else { return } guard !addedDishes.contains(where: { $0.name.lowercased() == normalizedName.lowercased() }) else { return } let matchingTags = availableTags.filter { tag in preferredTagNames.contains(where: { preferred in tag.name.caseInsensitiveCompare(preferred) == .orderedSame || tag.nameEN.caseInsensitiveCompare(preferred) == .orderedSame }) } let tagIds: [UUID] if !matchingTags.isEmpty { tagIds = matchingTags.map(\.id) } else if let fallback = availableTags.sorted(by: { $0.sortOrder < $1.sortOrder }).first { tagIds = [fallback.id] } else { tagIds = [] } addedDishes.append((name: normalizedName, tagIds: tagIds)) } func nextStep() { if currentStep < totalSteps - 1 { withAnimation(.easeInOut(duration: 0.3)) { currentStep += 1 } } } func previousStep() { if currentStep > 0 { withAnimation(.easeInOut(duration: 0.3)) { currentStep -= 1 } } } /// Persists the choices made during onboarding (settings, dishes, first week). /// /// `markFinished` must stay `false` while the flow is still on screen: the /// `onboardingCompleted` flag is what ContentView watches to swap onboarding /// for the home, so writing it here would tear the flow down before the last /// two steps (Week Ready and paywall) get a chance to render. Call /// `finishOnboarding(context:)` once the user leaves the final step. @discardableResult func completeOnboarding(context: ModelContext, markFinished: Bool = true) -> Bool { // Create or get settings let descriptor = FetchDescriptor() let settings = (try? context.fetch(descriptor))?.first ?? { let s = AppSettings() context.insert(s) return s }() settings.activeMealTypes = MealType.allCases.filter { selectedMealTypes.contains($0) } settings.includeWeekends = includeWeekends settings.iCloudSyncEnabled = syncICloud settings.syncEnabled = syncCalendar settings.calendarId = selectedCalendarId settings.lunchTime = lunchTime settings.dinnerTime = dinnerTime if markFinished { settings.onboardingCompleted = true } // Create default tags if not exist let tagDescriptor = FetchDescriptor() if (try? context.fetch(tagDescriptor))?.isEmpty ?? true { DefaultDataService.createDefaultTags(context: context) } // Create dishes for dishData in addedDishes { let dish = Dish(name: dishData.name, tagIds: dishData.tagIds) context.insert(dish) } let createdDishes = !addedDishes.isEmpty let onboardingDishCount = addedDishes.count // Create first week plan let weekStart = Date().startOfWeek() let _ = DefaultDataService.createWeekPlan(for: weekStart, settings: settings, context: context) do { try context.save() // Dishes created inside onboarding didn't previously fire dish_added, // so activation looked lower than it was. Emit one per dish, tagged. for dishData in addedDishes { AnalyticsService.logDishAdded(tagCount: dishData.tagIds.count, source: "onboarding") } AnalyticsService.logOnboardingCompleted(dishCount: onboardingDishCount) // Auto-fill now happens inline in the Week Ready onboarding step, so the // launch flag stays off. Users without dishes still get the home prompt. UserDefaults.standard.set(false, forKey: Self.pendingAutoFillOnLaunchKey) UserDefaults.standard.set(!createdDishes, forKey: Self.pendingAutoAssignPromptKey) UserDefaults.standard.set(true, forKey: Self.pendingPremiumPromptKey) return true } catch { CrashlyticsService.record(error, context: "onboarding_save") print("Onboarding save failed: \(error)") return false } } /// Flips the flag ContentView watches, handing the user over to the home. /// Separate from `completeOnboarding` so the data can be committed at the /// dishes step while the flow stays on screen for the last two steps. func finishOnboarding(context: ModelContext) { let descriptor = FetchDescriptor() guard let settings = (try? context.fetch(descriptor))?.first else { return } guard !settings.onboardingCompleted else { return } settings.onboardingCompleted = true do { try context.save() } catch { CrashlyticsService.record(error, context: "onboarding_finish") print("Onboarding finish failed: \(error)") } } /// Fills the first week right inside onboarding (the "aha moment" step) using /// the same engine as the home auto-complete. Returns the number of filled slots. @discardableResult func autoFillFirstWeek(context: ModelContext) -> Int { autoFilledCount = 0 guard !addedDishes.isEmpty else { return 0 } let dishes = (try? context.fetch(FetchDescriptor())) ?? [] let tags = (try? context.fetch(FetchDescriptor())) ?? [] let weekStart = Date().startOfWeek() let plans = (try? context.fetch(FetchDescriptor())) ?? [] guard let plan = plans.first(where: { $0.weekStartDate == weekStart }) else { return 0 } let emptySlots = plan.slotList.filter { $0.dishId == nil && !$0.isEatingOut } guard !emptySlots.isEmpty else { return 0 } let result = AutocompleteEngine.autocomplete( emptySlots: emptySlots, currentPlan: plan, allDishes: dishes, allTags: tags, recentPlans: [], rejectionCounts: FeedbackStore.rejectionCounts() ) plan.updatedAt = Date() try? context.save() AnalyticsService.logAutoAssignUsed( filledSlots: result.filledSlots.count, unfilledSlots: result.unfilledCount ) autoFilledCount = result.filledSlots.count return result.filledSlots.count } }