a0f9fa63d3
- MealSlot.secondaryDishId (opcional, aditivo CloudKit); se limpia al sustituir/quitar/vaciar/marcar comer fuera y viaja en swaps, copia de semana anterior, undo y snapshot KV de iCloud (retrocompatible) - Picker de hueco lleno: toggle "anadir como segundo plato" + boton para quitarlo; HomeViewModel.assignSecondaryDish/removeSecondaryDish - Se muestra "Plato + Segundo" en calendario, widget y export; la lista de la compra incluye los ingredientes del segundo; stats lo cuentan - Evento analytics secondary_dish_added; strings en 6 idiomas Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
282 lines
10 KiB
Swift
282 lines
10 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 }
|
|
}
|
|
|
|
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")
|
|
)
|
|
}
|
|
}
|