6fe16d8e29
- MealSlot.isSkipped: opcion "No planificar esta comida" en el picker de hueco vacio — cuenta como resuelto (semana completa, notificaciones, stats) y el autocompletado lo respeta; vista gris con guion, tap para desmarcar; viaja en undo, snapshot KV (junto con isEatingOut, que faltaba en el payload) y exports (— en imagen, omitido en texto) - ShoppingItem.isDismissed: "Ya lo tengo" por ingrediente (swipe izquierdo) y "Ya esta hecho" por plato entero (boton en la cabecera de su seccion); van a la seccion "Ya en casa" con restauracion de un tap, y quedan fuera del export de texto. No se borran porque reconcile los recrearia - Esquema CloudKit Development: CD_isSkipped, CD_isDismissed y el record type CD_ShoppingItem entero, que no existia — la lista de la compra no estaba sincronizando entre dispositivos (mismo bug que photoData) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
242 lines
9.3 KiB
Swift
242 lines
9.3 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
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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<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
|
|
if markFinished {
|
|
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
|
|
}
|
|
}
|
|
|
|
/// 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<AppSettings>()
|
|
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<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 && !$0.isSkipped }
|
|
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
|
|
}
|
|
}
|