import SwiftUI import SwiftData @MainActor final class DishViewModel: ObservableObject { @Published var name: String = "" @Published var descriptionText: String = "" @Published var selectedTagIds: Set = [] @Published var isPriority: Bool = false @Published var ingredients: [String] = [] @Published var photoData: Data? @Published var isGeneratingIngredients: Bool = false @Published var isParsingIngredients: Bool = false @Published var showTagSelector: Bool = false @Published var showDeleteAlert: Bool = false @Published var showAssignedDeleteAlert: Bool = false var editingDish: Dish? var isEditing: Bool { editingDish != nil } var isValid: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty } func loadDish(_ dish: Dish) { editingDish = dish name = dish.name descriptionText = dish.descriptionText ?? "" selectedTagIds = Set(dish.tagIds) isPriority = dish.isPriority ingredients = dish.ingredients photoData = dish.photoData } func reset() { editingDish = nil name = "" descriptionText = "" selectedTagIds = [] isPriority = false ingredients = [] photoData = nil } /// Generates ingredient lines on-device from the dish name and replaces /// the current draft list. No-op when unavailable or the name is empty. func generateIngredients(language: AppLanguage) { guard !isGeneratingIngredients, isValid else { return } isGeneratingIngredients = true let dishName = name Task { let lines = await IngredientGenerator.generate(dishName: dishName, language: language) await MainActor.run { self.isGeneratingIngredients = false guard !lines.isEmpty else { return } self.ingredients = lines PremiumAccess.recordIngredientGeneration() AnalyticsService.logIngredientsGenerated(lineCount: lines.count, source: "dish_editor") } } } /// Splits a dictated phrase into individual ingredient lines and appends /// them to the current draft, dropping any empty placeholder rows first. /// No-op on empty input or while a parse is already running. func addDictatedIngredients(_ text: String, language: AppLanguage) { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, !isParsingIngredients else { return } isParsingIngredients = true Task { let lines = await IngredientParser.parse(trimmed, language: language) await MainActor.run { self.isParsingIngredients = false guard !lines.isEmpty else { return } self.ingredients.removeAll { $0.trimmingCharacters(in: .whitespaces).isEmpty } self.ingredients.append(contentsOf: lines) AnalyticsService.logIngredientsGenerated(lineCount: lines.count, source: "dictation") } } } func save(context: ModelContext) { let trimmedName = name.trimmingCharacters(in: .whitespaces) guard !trimmedName.isEmpty else { return } let cleanIngredients = ingredients .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } if let dish = editingDish { dish.name = trimmedName dish.descriptionText = descriptionText.isEmpty ? nil : descriptionText dish.tagIds = Array(selectedTagIds) dish.isPriority = isPriority dish.ingredients = cleanIngredients dish.photoData = photoData } else { let dish = Dish( name: trimmedName, descriptionText: descriptionText.isEmpty ? nil : descriptionText, tagIds: Array(selectedTagIds), isPriority: isPriority, ingredients: cleanIngredients, photoData: photoData ) context.insert(dish) AnalyticsService.logDishAdded(tagCount: selectedTagIds.count) } try? context.save() reset() } func deleteDish(context: ModelContext) -> Bool { guard let dish = editingDish else { return false } if isDishAssignedInCurrentWeek(dishId: dish.id, context: context) { showAssignedDeleteAlert = true return false } context.delete(dish) try? context.save() AnalyticsService.logDishDeleted() reset() return true } func canDelete(currentPlan: WeekPlan?) -> Bool { guard let dish = editingDish, let plan = currentPlan else { return true } return !plan.slotList.contains { $0.dishId == dish.id } } private func isDishAssignedInCurrentWeek(dishId: UUID, context: ModelContext) -> Bool { let currentWeekStart = Date().startOfWeek() let descriptor = FetchDescriptor( predicate: #Predicate { plan in plan.weekStartDate == currentWeekStart } ) guard let currentPlan = try? context.fetch(descriptor).first else { return false } return currentPlan.slotList.contains { $0.dishId == dishId } } }