Files
FamilyMealPlanner/MealMood/Views/Shopping/ShoppingListView.swift
T
alexandrev-tibco 6fe16d8e29 huecos sin planificar y exclusiones en la lista de la compra
- MealSlot.isSkipped: opcion "No planificar esta comida" en el picker de
  hueco vacio — cuenta como resuelto (semana completa, notificaciones,
  stats) y el autocompletado lo respeta; vista gris con guion, tap para
  desmarcar; viaja en undo, snapshot KV (junto con isEatingOut, que
  faltaba en el payload) y exports (— en imagen, omitido en texto)
- ShoppingItem.isDismissed: "Ya lo tengo" por ingrediente (swipe
  izquierdo) y "Ya esta hecho" por plato entero (boton en la cabecera de
  su seccion); van a la seccion "Ya en casa" con restauracion de un tap,
  y quedan fuera del export de texto. No se borran porque reconcile los
  recrearia
- Esquema CloudKit Development: CD_isSkipped, CD_isDismissed y el record
  type CD_ShoppingItem entero, que no existia — la lista de la compra no
  estaba sincronizando entre dispositivos (mismo bug que photoData)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
2026-09-01 16:16:34 +02:00

339 lines
12 KiB
Swift

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<UUID>()
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 && !$0.isDismissed }
}
private var dismissedItems: [ShoppingItem] {
items.filter(\.isDismissed)
}
private func items(for dish: Dish) -> [ShoppingItem] {
items.filter { $0.dishId == dish.id && !$0.isDismissed }
}
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 {
ForEach(dishItems, id: \.id) { item in
itemRow(item)
}
} header: {
HStack {
Text(dish.name)
Spacer()
// "Already cooked": park every line of this dish
Button {
dismissDish(dish)
} label: {
Label("shopping_exclude_dish", systemImage: "takeoutbag.and.cup.and.straw")
.font(.mealMoodCaption.weight(.semibold))
.foregroundColor(.mealMoodCoral)
}
.buttonStyle(.borderless)
}
}
}
}
if !dishesWithoutIngredients.isEmpty {
Section("shopping_missing_ingredients") {
ForEach(dishesWithoutIngredients, id: \.id) { dish in
missingIngredientsRow(dish)
}
}
}
if !dismissedItems.isEmpty {
Section("shopping_at_home_section") {
ForEach(dismissedItems, id: \.id) { item in
Button {
item.isDismissed = false
HapticManager.shared.selection()
} label: {
HStack {
Image(systemName: "house.fill")
.foregroundColor(.mealMoodMint)
Text(item.title)
.foregroundColor(.mealMoodTextSecondary)
Spacer()
Image(systemName: "arrow.uturn.backward.circle")
.foregroundColor(.mealMoodTextSecondary)
}
}
.buttonStyle(.plain)
}
}
}
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")
}
}
.swipeActions(edge: .leading) {
Button {
item.isDismissed = true
HapticManager.shared.selection()
} label: {
Label("shopping_have_it", systemImage: "house")
}
.tint(.mealMoodMint)
}
}
private func dismissDish(_ dish: Dish) {
for item in items where item.dishId == dish.id {
item.isDismissed = true
}
AnalyticsService.logEvent("shopping_dish_excluded")
HapticManager.shared.notification(type: .success)
}
@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.filter { !$0.isDismissed },
header: String(localized: "shopping_share_header")
)
}
}