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,254 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// Weekly shopping list: aggregates the ingredients of the dishes planned for
|
||||
/// the given week. Dish-derived lines reconcile automatically with the plan;
|
||||
/// the user can check items off, add loose items, and generate ingredients
|
||||
/// on-device for planned dishes that have none yet.
|
||||
struct ShoppingListView: View {
|
||||
let weekStartDate: Date
|
||||
let plan: WeekPlan
|
||||
let settings: AppSettings
|
||||
|
||||
@Environment(\.modelContext) private var context
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Query private var allItems: [ShoppingItem]
|
||||
@Query private var allDishes: [Dish]
|
||||
|
||||
@State private var newItemText: String = ""
|
||||
@State private var generatingDishId: UUID?
|
||||
@State private var showPaywall = false
|
||||
|
||||
private var items: [ShoppingItem] {
|
||||
allItems
|
||||
.filter { $0.weekStartDate == weekStartDate }
|
||||
.sorted { $0.sortOrder < $1.sortOrder }
|
||||
}
|
||||
|
||||
/// Dishes planned this week, deduplicated, in slot order.
|
||||
private var plannedDishes: [Dish] {
|
||||
var seen = Set<UUID>()
|
||||
var result: [Dish] = []
|
||||
let sortedSlots = plan.slots.sorted {
|
||||
($0.dayOfWeek, $0.mealType) < ($1.dayOfWeek, $1.mealType)
|
||||
}
|
||||
for slot in sortedSlots {
|
||||
guard let dishId = slot.dishId, !seen.contains(dishId),
|
||||
let dish = allDishes.first(where: { $0.id == dishId }) else { continue }
|
||||
seen.insert(dishId)
|
||||
result.append(dish)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private var dishesWithoutIngredients: [Dish] {
|
||||
plannedDishes.filter { $0.ingredients.isEmpty }
|
||||
}
|
||||
|
||||
private var freeTextItems: [ShoppingItem] {
|
||||
items.filter { $0.dishId == nil }
|
||||
}
|
||||
|
||||
private func items(for dish: Dish) -> [ShoppingItem] {
|
||||
items.filter { $0.dishId == dish.id }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if items.isEmpty && dishesWithoutIngredients.isEmpty {
|
||||
emptyState
|
||||
}
|
||||
|
||||
ForEach(plannedDishes, id: \.id) { dish in
|
||||
let dishItems = items(for: dish)
|
||||
if !dishItems.isEmpty {
|
||||
Section(dish.name) {
|
||||
ForEach(dishItems, id: \.id) { item in
|
||||
itemRow(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !dishesWithoutIngredients.isEmpty {
|
||||
Section("shopping_missing_ingredients") {
|
||||
ForEach(dishesWithoutIngredients, id: \.id) { dish in
|
||||
missingIngredientsRow(dish)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("shopping_other_items") {
|
||||
ForEach(freeTextItems, id: \.id) { item in
|
||||
itemRow(item)
|
||||
}
|
||||
HStack {
|
||||
TextField("shopping_add_item_placeholder", text: $newItemText)
|
||||
.onSubmit(addFreeTextItem)
|
||||
Button(action: addFreeTextItem) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
.disabled(newItemText.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("shopping_list_title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("shopping_done") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 12) {
|
||||
ShareLink(item: exportText) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
.disabled(items.allSatisfy(\.isChecked))
|
||||
|
||||
Button {
|
||||
clearChecked()
|
||||
} label: {
|
||||
Image(systemName: "checkmark.circle.badge.xmark")
|
||||
}
|
||||
.disabled(!items.contains(where: \.isChecked))
|
||||
.accessibilityLabel(Text("shopping_clear_checked"))
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
ShoppingListService.reconcile(
|
||||
context: context,
|
||||
weekStartDate: weekStartDate,
|
||||
plannedDishes: plannedDishes
|
||||
)
|
||||
AnalyticsService.logShoppingListOpened(
|
||||
itemCount: items.count,
|
||||
missingDishes: dishesWithoutIngredients.count
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showPaywall) {
|
||||
PremiumView(settings: settings, source: "ingredient_generation")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "cart")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
Text("shopping_empty_title")
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Text("shopping_empty_message")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 24)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
private func itemRow(_ item: ShoppingItem) -> some View {
|
||||
Button {
|
||||
item.isChecked.toggle()
|
||||
HapticManager.shared.selection()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: item.isChecked ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundColor(item.isChecked ? .mealMoodCoral : .mealMoodTextSecondary)
|
||||
Text(item.title)
|
||||
.strikethrough(item.isChecked)
|
||||
.foregroundColor(item.isChecked ? .mealMoodTextSecondary : .mealMoodTextPrimary)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
context.delete(item)
|
||||
} label: {
|
||||
Label("shopping_delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func missingIngredientsRow(_ dish: Dish) -> some View {
|
||||
HStack {
|
||||
Text(dish.name)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Spacer()
|
||||
if IngredientGenerator.isAvailable {
|
||||
if generatingDishId == dish.id {
|
||||
ProgressView()
|
||||
} else {
|
||||
Button {
|
||||
generate(for: dish)
|
||||
} label: {
|
||||
Label("shopping_generate", systemImage: "sparkles")
|
||||
.font(.mealMoodSmall.weight(.semibold))
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func generate(for dish: Dish) {
|
||||
guard PremiumAccess.canGenerateIngredients(isPremium: settings.isPremium) else {
|
||||
showPaywall = true
|
||||
return
|
||||
}
|
||||
generatingDishId = dish.id
|
||||
Task {
|
||||
let lines = await IngredientGenerator.generate(
|
||||
dishName: dish.name,
|
||||
language: settings.languageEnum
|
||||
)
|
||||
await MainActor.run {
|
||||
generatingDishId = nil
|
||||
guard !lines.isEmpty else { return }
|
||||
dish.ingredients = lines
|
||||
PremiumAccess.recordIngredientGeneration()
|
||||
AnalyticsService.logIngredientsGenerated(
|
||||
lineCount: lines.count,
|
||||
source: "shopping_list"
|
||||
)
|
||||
ShoppingListService.reconcile(
|
||||
context: context,
|
||||
weekStartDate: weekStartDate,
|
||||
plannedDishes: plannedDishes
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addFreeTextItem() {
|
||||
let title = newItemText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !title.isEmpty else { return }
|
||||
let order = (items.map(\.sortOrder).max() ?? -1) + 1
|
||||
context.insert(
|
||||
ShoppingItem(weekStartDate: weekStartDate, title: title, sortOrder: order)
|
||||
)
|
||||
newItemText = ""
|
||||
AnalyticsService.logShoppingItemAdded()
|
||||
}
|
||||
|
||||
private func clearChecked() {
|
||||
for item in items where item.isChecked {
|
||||
context.delete(item)
|
||||
}
|
||||
}
|
||||
|
||||
private var exportText: String {
|
||||
ShoppingListService.exportText(
|
||||
items: items,
|
||||
header: String(localized: "shopping_share_header")
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user