2.0: weekly shopping list from planned dishes
- 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
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user