platos: segundo plato por comida (ensalada + salmon)

- 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
This commit is contained in:
alexandrev-tibco
2026-09-01 11:37:23 +02:00
parent 7193234024
commit a0f9fa63d3
15 changed files with 128 additions and 13 deletions
+49 -2
View File
@@ -570,7 +570,23 @@ struct HomeView: View {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
attemptCreateDish()
}
}
},
onPickSecondDish: { dish in
guard let freshSlot = plan.slotList.first(where: { $0.id == slotId }) else {
selectedFilledSlotId = nil
return
}
viewModel.assignSecondaryDish(dish, to: freshSlot, plan: plan, settings: settings)
selectedFilledSlotId = nil
},
onRemoveSecondDish: plan.slotList.first(where: { $0.id == slotId })?.secondaryDishId != nil ? {
guard let freshSlot = plan.slotList.first(where: { $0.id == slotId }) else {
selectedFilledSlotId = nil
return
}
viewModel.removeSecondaryDish(from: freshSlot, plan: plan)
selectedFilledSlotId = nil
} : nil
)
}
}
@@ -673,9 +689,14 @@ struct HomeView: View {
let onPickDish: (Dish) -> Void
let onCreateDish: () -> Void
var onEatingOut: (() -> Void)? = nil
/// Set for filled slots: taps add a companion dish instead of replacing
/// when the "second dish" mode is on.
var onPickSecondDish: ((Dish) -> Void)? = nil
var onRemoveSecondDish: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@State private var searchText: String = ""
@State private var asSecondDish: Bool = false
private var filteredDishes: [Dish] {
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -720,9 +741,35 @@ struct HomeView: View {
.listRowBackground(Color(hex: "#EEF8F5"))
}
if onPickSecondDish != nil, searchText.isEmpty {
Toggle(isOn: $asSecondDish) {
Label("home_pick_second_dish", systemImage: "plus.square.on.square")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextPrimary)
}
.tint(.mealMoodCoral)
.listRowBackground(Color.mealMoodSurface)
if let onRemoveSecondDish {
Button {
dismiss()
onRemoveSecondDish()
} label: {
Label("home_remove_second_dish", systemImage: "minus.square")
.font(.mealMoodBody)
.foregroundColor(.mealMoodError)
}
.listRowBackground(Color.mealMoodSurface)
}
}
ForEach(filteredDishes, id: \.id) { dish in
Button {
onPickDish(dish)
if asSecondDish, let onPickSecondDish {
onPickSecondDish(dish)
} else {
onPickDish(dish)
}
dismiss()
} label: {
VStack(alignment: .leading, spacing: 6) {
+5 -1
View File
@@ -214,6 +214,7 @@ struct WeekCalendarView: View {
dayOfWeek: day,
mealType: mealType.rawValue,
dishId: preferred.dishId,
secondaryDishId: preferred.secondaryDishId,
isRuleOverridden: preferred.isRuleOverridden,
isEatingOut: preferred.isEatingOut
)
@@ -264,8 +265,9 @@ struct WeekCalendarView: View {
if let slot = slot, let dishId = slot.dishId,
let dishData = dishData(for: dishId) {
let dishTags = tags.filter { dishData.tagIds.contains($0.id) }
let secondaryName = slot.secondaryDishId.flatMap { self.dishData(for: $0)?.name }
FilledSlotView(
dishName: dishData.name,
dishName: secondaryName.map { "\(dishData.name) + \($0)" } ?? dishData.name,
dishDescription: dishData.descriptionText,
tags: dishTags.map { (name: $0.localizedName(language: settings.languageEnum.resolved()), color: $0.color) },
mealType: mealType,
@@ -321,6 +323,7 @@ struct WeekCalendarView: View {
let dayOfWeek: Int
let mealType: String
let dishId: UUID?
let secondaryDishId: UUID?
let isRuleOverridden: Bool
let isEatingOut: Bool
}
@@ -370,6 +373,7 @@ struct WeekCalendarView: View {
dayOfWeek: preferred.dayOfWeek,
mealType: preferred.mealType,
dishId: preferred.dishId,
secondaryDishId: preferred.secondaryDishId,
isRuleOverridden: preferred.isRuleOverridden,
isEatingOut: preferred.isEatingOut
)
@@ -513,6 +513,10 @@ struct WeekPlanShareView: View {
guard let dishId = slot.dishId, let dish = dishes.first(where: { $0.id == dishId }) else {
return String(localized: "share_slot_empty")
}
if let secondaryId = slot.secondaryDishId,
let secondary = dishes.first(where: { $0.id == secondaryId }) {
return "\(dish.name) + \(secondary.name)"
}
return dish.name
}
@@ -34,10 +34,12 @@ struct ShoppingListView: View {
($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)
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
}
+5 -2
View File
@@ -172,6 +172,7 @@ struct StatsView: View {
for plan in weekPlans {
for slot in plan.slotList {
if let id = slot.dishId { counts[id, default: 0] += 1 }
if let id = slot.secondaryDishId { counts[id, default: 0] += 1 }
}
}
return counts
@@ -197,8 +198,10 @@ struct StatsView: View {
var counts: [UUID: Int] = [:]
for plan in weekPlans {
for slot in plan.slotList {
guard let dishId = slot.dishId, let dish = dishMap[dishId] else { continue }
for tagId in dish.tagIds { counts[tagId, default: 0] += 1 }
for dishId in [slot.dishId, slot.secondaryDishId].compactMap({ $0 }) {
guard let dish = dishMap[dishId] else { continue }
for tagId in dish.tagIds { counts[tagId, default: 0] += 1 }
}
}
}
return counts