7193234024
- Dish.fixedDayOfWeek/fixedMealType (opcionales, aditivos para CloudKit) - Toggle + pickers de dia/comida en el formulario de plato - AutocompleteEngine: pre-pasada que asigna los platos fijos a su hueco antes del bucle MRV, de modo que cuentan para el resto de reglas - Evento analytics dish_fixed_slot_set; strings en 6 idiomas Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
168 lines
6.1 KiB
Swift
168 lines
6.1 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
|
|
@MainActor
|
|
final class DishViewModel: ObservableObject {
|
|
@Published var name: String = ""
|
|
@Published var descriptionText: String = ""
|
|
@Published var selectedTagIds: Set<UUID> = []
|
|
@Published var isPriority: Bool = false
|
|
@Published var hasFixedSlot: Bool = false
|
|
@Published var fixedDayOfWeek: Int = 4 // Friday
|
|
@Published var fixedMealType: MealType = .dinner
|
|
@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
|
|
if let day = dish.fixedDayOfWeek, let meal = dish.fixedMealType.flatMap(MealType.init) {
|
|
hasFixedSlot = true
|
|
fixedDayOfWeek = day
|
|
fixedMealType = meal
|
|
} else {
|
|
hasFixedSlot = false
|
|
}
|
|
ingredients = dish.ingredients
|
|
photoData = dish.photoData
|
|
}
|
|
|
|
func reset() {
|
|
editingDish = nil
|
|
name = ""
|
|
descriptionText = ""
|
|
selectedTagIds = []
|
|
isPriority = false
|
|
hasFixedSlot = false
|
|
fixedDayOfWeek = 4
|
|
fixedMealType = .dinner
|
|
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.fixedDayOfWeek = hasFixedSlot ? fixedDayOfWeek : nil
|
|
dish.fixedMealType = hasFixedSlot ? fixedMealType.rawValue : nil
|
|
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,
|
|
fixedDayOfWeek: hasFixedSlot ? fixedDayOfWeek : nil,
|
|
fixedMealType: hasFixedSlot ? fixedMealType.rawValue : nil
|
|
)
|
|
context.insert(dish)
|
|
AnalyticsService.logDishAdded(tagCount: selectedTagIds.count)
|
|
}
|
|
if hasFixedSlot { AnalyticsService.logEvent("dish_fixed_slot_set") }
|
|
|
|
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<WeekPlan>(
|
|
predicate: #Predicate<WeekPlan> { plan in
|
|
plan.weekStartDate == currentWeekStart
|
|
}
|
|
)
|
|
|
|
guard let currentPlan = try? context.fetch(descriptor).first else {
|
|
return false
|
|
}
|
|
|
|
return currentPlan.slotList.contains { $0.dishId == dishId }
|
|
}
|
|
}
|