ce88d7b265
- ShoppingItem model (per-week lines; dish-derived or free-text) + schema. - ShoppingListService.reconcile mirrors planned dishes' ingredients into the list, preserving check-off state and user items; plain-text export helper. - ShoppingListView: grouped by dish, check-off, swipe-delete, free-text adds, clear-checked, ShareLink export, and inline on-device "Generate" for planned dishes without ingredients (paywall after 3 free generations, source=ingredient_generation). - Home toolbar: cart entry point (all users) + sheet. - Analytics: shopping_list_opened, shopping_item_added, ingredients_generated. - Localized in all 6 languages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG
73 lines
2.6 KiB
Swift
73 lines
2.6 KiB
Swift
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<ShoppingItem>(
|
|
predicate: #Predicate<ShoppingItem> { $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()
|
|
}
|
|
}
|