Files
FamilyMealPlanner/MealMood/ViewModels/OnboardingViewModel.swift
T
alexandrev-tibco 4ffa15b06a 2.0: CloudKit private-database sync replaces KV snapshot sync
Foundation for 2.1 family sharing (CKShare needs these models + container).
SwiftData cannot share across Apple IDs today, so 2.0 ships true multi-device
sync for the same account instead:

- Models made CloudKit-compatible: dropped @Attribute(.unique) on all 5,
  inline defaults on every attribute, WeekPlan.slots stored as optional
  relationship (name preserved → lightweight migration) with non-optional
  slotList facade; ~73 call sites renamed.
- Container: cloudKitDatabase .automatic, falling back to the local-only
  store when CloudKit is unavailable; failures recorded to Crashlytics.
- Entitlements: iCloud CloudKit service (container already existed);
  remote-notification background mode for push-driven sync.
- Legacy KV snapshot sync (ICloudSyncService) stays inert when CloudKit is
  active — kept only for 1.x devices.
- DeduplicationService collapses cross-device duplicates deterministically on
  launch (settings singleton, default tags with tagId remapping, same-week
  plans).
- Note: AppSettings.isPremium now syncs across same-Apple-ID devices; StoreKit
  (PremiumSyncService + Transaction.updates) remains the source of truth and
  reconciles on every launch/foreground.

All 21 unit tests pass, including ICloudSyncPremiumIsolationTests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG
2026-07-12 18:13:45 +02:00

216 lines
8.1 KiB
Swift

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<MealType> = [.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<UUID> = []
@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
}
}
}
@discardableResult
func completeOnboarding(context: ModelContext) -> Bool {
// Create or get settings
let descriptor = FetchDescriptor<AppSettings>()
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
settings.onboardingCompleted = true
// Create default tags if not exist
let tagDescriptor = FetchDescriptor<Tag>()
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
}
}
/// 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<Dish>())) ?? []
let tags = (try? context.fetch(FetchDescriptor<Tag>())) ?? []
let weekStart = Date().startOfWeek()
let plans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
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
}
}