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
+5
View File
@@ -8,6 +8,9 @@ final class MealSlot {
var dayOfWeek: Int = 0 // 0=Monday, 6=Sunday var dayOfWeek: Int = 0 // 0=Monday, 6=Sunday
var mealType: String = MealType.dinner.rawValue var mealType: String = MealType.dinner.rawValue
var dishId: UUID? var dishId: UUID?
/// Optional companion dish ("ensalada + salmón"). Only meaningful while
/// `dishId` is set; cleared together with it.
var secondaryDishId: UUID?
var calendarEventId: String? var calendarEventId: String?
var isRuleOverridden: Bool = false var isRuleOverridden: Bool = false
var isEatingOut: Bool = false var isEatingOut: Bool = false
@@ -19,6 +22,7 @@ final class MealSlot {
dayOfWeek: Int, dayOfWeek: Int,
mealType: String, mealType: String,
dishId: UUID? = nil, dishId: UUID? = nil,
secondaryDishId: UUID? = nil,
calendarEventId: String? = nil, calendarEventId: String? = nil,
isRuleOverridden: Bool = false, isRuleOverridden: Bool = false,
isEatingOut: Bool = false isEatingOut: Bool = false
@@ -27,6 +31,7 @@ final class MealSlot {
self.dayOfWeek = dayOfWeek self.dayOfWeek = dayOfWeek
self.mealType = mealType self.mealType = mealType
self.dishId = dishId self.dishId = dishId
self.secondaryDishId = secondaryDishId
self.calendarEventId = calendarEventId self.calendarEventId = calendarEventId
self.isRuleOverridden = isRuleOverridden self.isRuleOverridden = isRuleOverridden
self.isEatingOut = isEatingOut self.isEatingOut = isEatingOut
@@ -426,3 +426,5 @@
"dish_fixed_slot_desc" = "Beim Ausfüllen immer am selben Tag und zur selben Mahlzeit"; "dish_fixed_slot_desc" = "Beim Ausfüllen immer am selben Tag und zur selben Mahlzeit";
"dish_fixed_slot_day" = "Tag"; "dish_fixed_slot_day" = "Tag";
"dish_fixed_slot_meal" = "Mahlzeit"; "dish_fixed_slot_meal" = "Mahlzeit";
"home_pick_second_dish" = "Als zweites Gericht hinzufügen";
"home_remove_second_dish" = "Zweites Gericht entfernen";
@@ -426,3 +426,5 @@
"dish_fixed_slot_desc" = "Always on the same day and meal when auto-filling"; "dish_fixed_slot_desc" = "Always on the same day and meal when auto-filling";
"dish_fixed_slot_day" = "Day"; "dish_fixed_slot_day" = "Day";
"dish_fixed_slot_meal" = "Meal"; "dish_fixed_slot_meal" = "Meal";
"home_pick_second_dish" = "Add as second dish";
"home_remove_second_dish" = "Remove second dish";
@@ -426,3 +426,5 @@
"dish_fixed_slot_desc" = "Siempre en el mismo día y comida al autocompletar"; "dish_fixed_slot_desc" = "Siempre en el mismo día y comida al autocompletar";
"dish_fixed_slot_day" = "Día"; "dish_fixed_slot_day" = "Día";
"dish_fixed_slot_meal" = "Comida"; "dish_fixed_slot_meal" = "Comida";
"home_pick_second_dish" = "Añadir como segundo plato";
"home_remove_second_dish" = "Quitar el segundo plato";
@@ -426,3 +426,5 @@
"dish_fixed_slot_desc" = "Toujours le même jour et le même repas lors du remplissage auto"; "dish_fixed_slot_desc" = "Toujours le même jour et le même repas lors du remplissage auto";
"dish_fixed_slot_day" = "Jour"; "dish_fixed_slot_day" = "Jour";
"dish_fixed_slot_meal" = "Repas"; "dish_fixed_slot_meal" = "Repas";
"home_pick_second_dish" = "Ajouter comme deuxième plat";
"home_remove_second_dish" = "Retirer le deuxième plat";
@@ -426,3 +426,5 @@
"dish_fixed_slot_desc" = "Sempre nello stesso giorno e pasto con il riempimento automatico"; "dish_fixed_slot_desc" = "Sempre nello stesso giorno e pasto con il riempimento automatico";
"dish_fixed_slot_day" = "Giorno"; "dish_fixed_slot_day" = "Giorno";
"dish_fixed_slot_meal" = "Pasto"; "dish_fixed_slot_meal" = "Pasto";
"home_pick_second_dish" = "Aggiungi come secondo piatto";
"home_remove_second_dish" = "Rimuovi il secondo piatto";
@@ -426,3 +426,5 @@
"dish_fixed_slot_desc" = "Sempre no mesmo dia e refeição no preenchimento automático"; "dish_fixed_slot_desc" = "Sempre no mesmo dia e refeição no preenchimento automático";
"dish_fixed_slot_day" = "Dia"; "dish_fixed_slot_day" = "Dia";
"dish_fixed_slot_meal" = "Refeição"; "dish_fixed_slot_meal" = "Refeição";
"home_pick_second_dish" = "Adicionar como segundo prato";
"home_remove_second_dish" = "Remover o segundo prato";
@@ -115,6 +115,7 @@ final class ICloudSyncService {
dayOfWeek: $0.dayOfWeek, dayOfWeek: $0.dayOfWeek,
mealType: $0.mealType, mealType: $0.mealType,
dishId: $0.dishId, dishId: $0.dishId,
secondaryDishId: $0.secondaryDishId,
calendarEventId: $0.calendarEventId, calendarEventId: $0.calendarEventId,
isRuleOverridden: $0.isRuleOverridden isRuleOverridden: $0.isRuleOverridden
) )
@@ -210,6 +211,7 @@ final class ICloudSyncService {
dayOfWeek: slotPayload.dayOfWeek, dayOfWeek: slotPayload.dayOfWeek,
mealType: slotPayload.mealType, mealType: slotPayload.mealType,
dishId: slotPayload.dishId, dishId: slotPayload.dishId,
secondaryDishId: slotPayload.secondaryDishId,
calendarEventId: slotPayload.calendarEventId, calendarEventId: slotPayload.calendarEventId,
isRuleOverridden: slotPayload.isRuleOverridden isRuleOverridden: slotPayload.isRuleOverridden
) )
@@ -289,6 +291,8 @@ private struct MealSlotPayload: Codable {
let dayOfWeek: Int let dayOfWeek: Int
let mealType: String let mealType: String
let dishId: UUID? let dishId: UUID?
// Optional so payloads written by older installs (no key) still decode.
var secondaryDishId: UUID?
let calendarEventId: String? let calendarEventId: String?
let isRuleOverridden: Bool let isRuleOverridden: Bool
} }
+5 -3
View File
@@ -26,10 +26,12 @@ enum WidgetDataStore {
let appDayOfWeek = (weekday + 5) % 7 let appDayOfWeek = (weekday + 5) % 7
let meals: [TodayMealData.Meal] = settings.activeMealTypes.map { mealType in let meals: [TodayMealData.Meal] = settings.activeMealTypes.map { mealType in
let dishId = plan?.slotList.first { let slot = plan?.slotList.first {
$0.dayOfWeek == appDayOfWeek && $0.mealType == mealType.rawValue $0.dayOfWeek == appDayOfWeek && $0.mealType == mealType.rawValue
}?.dishId }
let name = dishId.flatMap { id in dishes.first { $0.id == id }?.name } let primaryName = slot?.dishId.flatMap { id in dishes.first { $0.id == id }?.name }
let secondaryName = slot?.secondaryDishId.flatMap { id in dishes.first { $0.id == id }?.name }
let name = primaryName.map { p in secondaryName.map { "\(p) + \($0)" } ?? p }
return TodayMealData.Meal( return TodayMealData.Meal(
type: mealType.rawValue, type: mealType.rawValue,
label: String(localized: String.LocalizationValue(mealType.localizedKey)), label: String(localized: String.LocalizationValue(mealType.localizedKey)),
+33 -1
View File
@@ -11,6 +11,7 @@ final class HomeViewModel: ObservableObject {
private struct SlotSnapshot { private struct SlotSnapshot {
let dishId: UUID? let dishId: UUID?
let secondaryDishId: UUID?
let isRuleOverridden: Bool let isRuleOverridden: Bool
let isEatingOut: Bool let isEatingOut: Bool
} }
@@ -94,6 +95,7 @@ final class HomeViewModel: ObservableObject {
} }
slot.dishId = dish.id slot.dishId = dish.id
slot.secondaryDishId = nil
slot.isRuleOverridden = isOverride slot.isRuleOverridden = isOverride
plan.updatedAt = Date() plan.updatedAt = Date()
@@ -104,6 +106,25 @@ final class HomeViewModel: ObservableObject {
showToastMessage(localizedString("toast_dish_assigned", language: settings.languageEnum.resolved())) showToastMessage(localizedString("toast_dish_assigned", language: settings.languageEnum.resolved()))
} }
/// Adds a companion dish to an already-filled slot ("ensalada + salmón").
func assignSecondaryDish(_ dish: Dish, to slot: MealSlot, plan: WeekPlan, settings: AppSettings) {
guard slot.dishId != nil, slot.dishId != dish.id else { return }
captureUndoSnapshot(plan: plan)
slot.secondaryDishId = dish.id
plan.updatedAt = Date()
AnalyticsService.logEvent("secondary_dish_added")
HapticManager.shared.notification(type: .success)
showToastMessage(localizedString("toast_dish_assigned", language: settings.languageEnum.resolved()))
}
func removeSecondaryDish(from slot: MealSlot, plan: WeekPlan) {
guard slot.secondaryDishId != nil else { return }
captureUndoSnapshot(plan: plan)
slot.secondaryDishId = nil
plan.updatedAt = Date()
HapticManager.shared.notification(type: .warning)
}
func removeDish(from slot: MealSlot, plan: WeekPlan, settings: AppSettings) { func removeDish(from slot: MealSlot, plan: WeekPlan, settings: AppSettings) {
captureUndoSnapshot(plan: plan) captureUndoSnapshot(plan: plan)
@@ -112,6 +133,7 @@ final class HomeViewModel: ObservableObject {
slot.calendarEventId = nil slot.calendarEventId = nil
} }
slot.dishId = nil slot.dishId = nil
slot.secondaryDishId = nil
slot.isRuleOverridden = false slot.isRuleOverridden = false
plan.updatedAt = Date() plan.updatedAt = Date()
applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false) applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false)
@@ -182,6 +204,7 @@ final class HomeViewModel: ObservableObject {
slot.calendarEventId = nil slot.calendarEventId = nil
} }
slot.dishId = nil slot.dishId = nil
slot.secondaryDishId = nil
slot.isRuleOverridden = false slot.isRuleOverridden = false
slot.isEatingOut = false slot.isEatingOut = false
} }
@@ -247,8 +270,11 @@ final class HomeViewModel: ObservableObject {
} }
let targetDishId = targetSlot.dishId let targetDishId = targetSlot.dishId
let targetSecondaryId = targetSlot.secondaryDishId
sourceSlot.dishId = targetDishId sourceSlot.dishId = targetDishId
targetSlot.dishId = sourceDishId targetSlot.dishId = sourceDishId
targetSlot.secondaryDishId = sourceSlot.secondaryDishId
sourceSlot.secondaryDishId = targetSecondaryId
sourceSlot.isRuleOverridden = false sourceSlot.isRuleOverridden = false
targetSlot.isRuleOverridden = false targetSlot.isRuleOverridden = false
@@ -293,9 +319,12 @@ final class HomeViewModel: ObservableObject {
let key = SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType) let key = SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType)
if let previousDishId = previousByKey[key]?.dishId, dishIds.contains(previousDishId) { if let previousDishId = previousByKey[key]?.dishId, dishIds.contains(previousDishId) {
slot.dishId = previousDishId slot.dishId = previousDishId
let previousSecondary = previousByKey[key]?.secondaryDishId
slot.secondaryDishId = previousSecondary.flatMap { dishIds.contains($0) ? $0 : nil }
copiedCount += 1 copiedCount += 1
} else { } else {
slot.dishId = nil slot.dishId = nil
slot.secondaryDishId = nil
} }
slot.isRuleOverridden = false slot.isRuleOverridden = false
updateRuleOverrideFlag(for: slot, plan: currentPlan, allTags: allTags, allDishes: allDishes) updateRuleOverrideFlag(for: slot, plan: currentPlan, allTags: allTags, allDishes: allDishes)
@@ -324,10 +353,12 @@ final class HomeViewModel: ObservableObject {
if let snap = lastSlotsSnapshot[slot.id] { if let snap = lastSlotsSnapshot[slot.id] {
slot.dishId = snap.dishId slot.dishId = snap.dishId
slot.secondaryDishId = snap.secondaryDishId
slot.isRuleOverridden = snap.isRuleOverridden slot.isRuleOverridden = snap.isRuleOverridden
slot.isEatingOut = snap.isEatingOut slot.isEatingOut = snap.isEatingOut
} else { } else {
slot.dishId = nil slot.dishId = nil
slot.secondaryDishId = nil
slot.isRuleOverridden = false slot.isRuleOverridden = false
slot.isEatingOut = false slot.isEatingOut = false
} }
@@ -353,7 +384,7 @@ final class HomeViewModel: ObservableObject {
guard !isApplyingUndo else { return } guard !isApplyingUndo else { return }
lastWeekStartSnapshot = plan.weekStartDate lastWeekStartSnapshot = plan.weekStartDate
lastSlotsSnapshot = Dictionary(plan.slotList.map { slot in lastSlotsSnapshot = Dictionary(plan.slotList.map { slot in
(slot.id, SlotSnapshot(dishId: slot.dishId, isRuleOverridden: slot.isRuleOverridden, isEatingOut: slot.isEatingOut)) (slot.id, SlotSnapshot(dishId: slot.dishId, secondaryDishId: slot.secondaryDishId, isRuleOverridden: slot.isRuleOverridden, isEatingOut: slot.isEatingOut))
}, uniquingKeysWith: { first, _ in first }) }, uniquingKeysWith: { first, _ in first })
hasUndoSnapshot = true hasUndoSnapshot = true
} }
@@ -463,6 +494,7 @@ final class HomeViewModel: ObservableObject {
slot.calendarEventId = nil slot.calendarEventId = nil
} }
slot.dishId = nil slot.dishId = nil
slot.secondaryDishId = nil
slot.isRuleOverridden = false slot.isRuleOverridden = false
slot.isEatingOut = true slot.isEatingOut = true
plan.updatedAt = Date() plan.updatedAt = Date()
+49 -2
View File
@@ -570,7 +570,23 @@ struct HomeView: View {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
attemptCreateDish() 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 onPickDish: (Dish) -> Void
let onCreateDish: () -> Void let onCreateDish: () -> Void
var onEatingOut: (() -> Void)? = nil 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 @Environment(\.dismiss) private var dismiss
@State private var searchText: String = "" @State private var searchText: String = ""
@State private var asSecondDish: Bool = false
private var filteredDishes: [Dish] { private var filteredDishes: [Dish] {
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -720,9 +741,35 @@ struct HomeView: View {
.listRowBackground(Color(hex: "#EEF8F5")) .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 ForEach(filteredDishes, id: \.id) { dish in
Button { Button {
onPickDish(dish) if asSecondDish, let onPickSecondDish {
onPickSecondDish(dish)
} else {
onPickDish(dish)
}
dismiss() dismiss()
} label: { } label: {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
+5 -1
View File
@@ -214,6 +214,7 @@ struct WeekCalendarView: View {
dayOfWeek: day, dayOfWeek: day,
mealType: mealType.rawValue, mealType: mealType.rawValue,
dishId: preferred.dishId, dishId: preferred.dishId,
secondaryDishId: preferred.secondaryDishId,
isRuleOverridden: preferred.isRuleOverridden, isRuleOverridden: preferred.isRuleOverridden,
isEatingOut: preferred.isEatingOut isEatingOut: preferred.isEatingOut
) )
@@ -264,8 +265,9 @@ struct WeekCalendarView: View {
if let slot = slot, let dishId = slot.dishId, if let slot = slot, let dishId = slot.dishId,
let dishData = dishData(for: dishId) { let dishData = dishData(for: dishId) {
let dishTags = tags.filter { dishData.tagIds.contains($0.id) } let dishTags = tags.filter { dishData.tagIds.contains($0.id) }
let secondaryName = slot.secondaryDishId.flatMap { self.dishData(for: $0)?.name }
FilledSlotView( FilledSlotView(
dishName: dishData.name, dishName: secondaryName.map { "\(dishData.name) + \($0)" } ?? dishData.name,
dishDescription: dishData.descriptionText, dishDescription: dishData.descriptionText,
tags: dishTags.map { (name: $0.localizedName(language: settings.languageEnum.resolved()), color: $0.color) }, tags: dishTags.map { (name: $0.localizedName(language: settings.languageEnum.resolved()), color: $0.color) },
mealType: mealType, mealType: mealType,
@@ -321,6 +323,7 @@ struct WeekCalendarView: View {
let dayOfWeek: Int let dayOfWeek: Int
let mealType: String let mealType: String
let dishId: UUID? let dishId: UUID?
let secondaryDishId: UUID?
let isRuleOverridden: Bool let isRuleOverridden: Bool
let isEatingOut: Bool let isEatingOut: Bool
} }
@@ -370,6 +373,7 @@ struct WeekCalendarView: View {
dayOfWeek: preferred.dayOfWeek, dayOfWeek: preferred.dayOfWeek,
mealType: preferred.mealType, mealType: preferred.mealType,
dishId: preferred.dishId, dishId: preferred.dishId,
secondaryDishId: preferred.secondaryDishId,
isRuleOverridden: preferred.isRuleOverridden, isRuleOverridden: preferred.isRuleOverridden,
isEatingOut: preferred.isEatingOut isEatingOut: preferred.isEatingOut
) )
@@ -513,6 +513,10 @@ struct WeekPlanShareView: View {
guard let dishId = slot.dishId, let dish = dishes.first(where: { $0.id == dishId }) else { guard let dishId = slot.dishId, let dish = dishes.first(where: { $0.id == dishId }) else {
return String(localized: "share_slot_empty") 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 return dish.name
} }
@@ -34,10 +34,12 @@ struct ShoppingListView: View {
($0.dayOfWeek, $0.mealType) < ($1.dayOfWeek, $1.mealType) ($0.dayOfWeek, $0.mealType) < ($1.dayOfWeek, $1.mealType)
} }
for slot in sortedSlots { for slot in sortedSlots {
guard let dishId = slot.dishId, !seen.contains(dishId), for dishId in [slot.dishId, slot.secondaryDishId].compactMap({ $0 }) {
let dish = allDishes.first(where: { $0.id == dishId }) else { continue } guard !seen.contains(dishId),
seen.insert(dishId) let dish = allDishes.first(where: { $0.id == dishId }) else { continue }
result.append(dish) seen.insert(dishId)
result.append(dish)
}
} }
return result return result
} }
+5 -2
View File
@@ -172,6 +172,7 @@ struct StatsView: View {
for plan in weekPlans { for plan in weekPlans {
for slot in plan.slotList { for slot in plan.slotList {
if let id = slot.dishId { counts[id, default: 0] += 1 } if let id = slot.dishId { counts[id, default: 0] += 1 }
if let id = slot.secondaryDishId { counts[id, default: 0] += 1 }
} }
} }
return counts return counts
@@ -197,8 +198,10 @@ struct StatsView: View {
var counts: [UUID: Int] = [:] var counts: [UUID: Int] = [:]
for plan in weekPlans { for plan in weekPlans {
for slot in plan.slotList { for slot in plan.slotList {
guard let dishId = slot.dishId, let dish = dishMap[dishId] else { continue } for dishId in [slot.dishId, slot.secondaryDishId].compactMap({ $0 }) {
for tagId in dish.tagIds { counts[tagId, default: 0] += 1 } guard let dish = dishMap[dishId] else { continue }
for tagId in dish.tagIds { counts[tagId, default: 0] += 1 }
}
} }
} }
return counts return counts