Files
FamilyMealPlanner/MealMood/Services/ShoppingListService.swift
T
alexandrev-tibco ce88d7b265 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
2026-07-12 17:47:41 +02:00

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()
}
}