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 @State private var editingDish: Dish? 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() var result: [Dish] = [] let sortedSlots = plan.slotList.sorted { ($0.dayOfWeek, $0.mealType) < ($1.dayOfWeek, $1.mealType) } for slot in sortedSlots { for dishId in [slot.dishId, slot.secondaryDishId].compactMap({ $0 }) { guard !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") } .sheet(item: $editingDish, onDismiss: { // Pick up ingredients the user just added in the editor. ShoppingListService.reconcile( context: context, weekStartDate: weekStartDate, plannedDishes: plannedDishes ) }) { dish in DishFormView(dish: dish) } } } 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 { // Tapping the dish opens its editor — the only path to add // ingredients on devices without on-device generation. Button { editingDish = dish } label: { HStack(spacing: 6) { Text(dish.name) .foregroundColor(.mealMoodTextPrimary) Image(systemName: "square.and.pencil") .font(.system(size: 13)) .foregroundColor(.mealMoodTextSecondary) } } .buttonStyle(.borderless) 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") ) } }