import Foundation import SwiftData /// Keeps the persisted shopping items for a week in sync with the planned /// dishes' ingredients, preserving check-off state and user-added items. enum ShoppingListService { /// Mirrors the current plan into shopping items: /// - inserts a line for every ingredient of every planned dish that isn't /// there yet, /// - removes dish-derived lines whose dish is no longer planned (or whose /// ingredient text changed), /// - never touches free-text items (`dishId == nil`) or check-off state. static func reconcile( context: ModelContext, weekStartDate: Date, plannedDishes: [Dish] ) { let descriptor = FetchDescriptor( predicate: #Predicate { $0.weekStartDate == weekStartDate } ) let existing = (try? context.fetch(descriptor)) ?? [] var desired: [(dishId: UUID, title: String)] = [] for dish in plannedDishes { for line in dish.ingredients { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { desired.append((dish.id, trimmed)) } } } let desiredKeys = Set(desired.map { key($0.dishId, $0.title) }) let existingKeys = Set(existing.compactMap { item in item.dishId.map { key($0, item.title) } }) for item in existing { guard let dishId = item.dishId else { continue } if !desiredKeys.contains(key(dishId, item.title)) { context.delete(item) } } var order = (existing.map(\.sortOrder).max() ?? -1) + 1 for entry in desired where !existingKeys.contains(key(entry.dishId, entry.title)) { context.insert( ShoppingItem( weekStartDate: weekStartDate, title: entry.title, dishId: entry.dishId, sortOrder: order ) ) order += 1 } } /// Plain-text export of the unchecked items (for the system share sheet). static func exportText(items: [ShoppingItem], header: String) -> String { let lines = items .filter { !$0.isChecked } .sorted { $0.sortOrder < $1.sortOrder } .map { "ยท \($0.title)" } return ([header] + lines).joined(separator: "\n") } private static func key(_ dishId: UUID, _ title: String) -> String { dishId.uuidString + "|" + title.lowercased() } }