diff --git a/MealMood/Models/MealSlot.swift b/MealMood/Models/MealSlot.swift index 7a73b8c..94e392c 100644 --- a/MealMood/Models/MealSlot.swift +++ b/MealMood/Models/MealSlot.swift @@ -8,6 +8,9 @@ final class MealSlot { var dayOfWeek: Int = 0 // 0=Monday, 6=Sunday var mealType: String = MealType.dinner.rawValue 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 isRuleOverridden: Bool = false var isEatingOut: Bool = false @@ -19,6 +22,7 @@ final class MealSlot { dayOfWeek: Int, mealType: String, dishId: UUID? = nil, + secondaryDishId: UUID? = nil, calendarEventId: String? = nil, isRuleOverridden: Bool = false, isEatingOut: Bool = false @@ -27,6 +31,7 @@ final class MealSlot { self.dayOfWeek = dayOfWeek self.mealType = mealType self.dishId = dishId + self.secondaryDishId = secondaryDishId self.calendarEventId = calendarEventId self.isRuleOverridden = isRuleOverridden self.isEatingOut = isEatingOut diff --git a/MealMood/Resources/de.lproj/Localizable.strings b/MealMood/Resources/de.lproj/Localizable.strings index 3afbd1b..bbdb191 100644 --- a/MealMood/Resources/de.lproj/Localizable.strings +++ b/MealMood/Resources/de.lproj/Localizable.strings @@ -426,3 +426,5 @@ "dish_fixed_slot_desc" = "Beim Ausfüllen immer am selben Tag und zur selben Mahlzeit"; "dish_fixed_slot_day" = "Tag"; "dish_fixed_slot_meal" = "Mahlzeit"; +"home_pick_second_dish" = "Als zweites Gericht hinzufügen"; +"home_remove_second_dish" = "Zweites Gericht entfernen"; diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index e8ea363..506dfd5 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -426,3 +426,5 @@ "dish_fixed_slot_desc" = "Always on the same day and meal when auto-filling"; "dish_fixed_slot_day" = "Day"; "dish_fixed_slot_meal" = "Meal"; +"home_pick_second_dish" = "Add as second dish"; +"home_remove_second_dish" = "Remove second dish"; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index 5d5d92f..66d369e 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -426,3 +426,5 @@ "dish_fixed_slot_desc" = "Siempre en el mismo día y comida al autocompletar"; "dish_fixed_slot_day" = "Día"; "dish_fixed_slot_meal" = "Comida"; +"home_pick_second_dish" = "Añadir como segundo plato"; +"home_remove_second_dish" = "Quitar el segundo plato"; diff --git a/MealMood/Resources/fr.lproj/Localizable.strings b/MealMood/Resources/fr.lproj/Localizable.strings index 7839ca9..0150317 100644 --- a/MealMood/Resources/fr.lproj/Localizable.strings +++ b/MealMood/Resources/fr.lproj/Localizable.strings @@ -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_day" = "Jour"; "dish_fixed_slot_meal" = "Repas"; +"home_pick_second_dish" = "Ajouter comme deuxième plat"; +"home_remove_second_dish" = "Retirer le deuxième plat"; diff --git a/MealMood/Resources/it.lproj/Localizable.strings b/MealMood/Resources/it.lproj/Localizable.strings index 0fa3093..df99619 100644 --- a/MealMood/Resources/it.lproj/Localizable.strings +++ b/MealMood/Resources/it.lproj/Localizable.strings @@ -426,3 +426,5 @@ "dish_fixed_slot_desc" = "Sempre nello stesso giorno e pasto con il riempimento automatico"; "dish_fixed_slot_day" = "Giorno"; "dish_fixed_slot_meal" = "Pasto"; +"home_pick_second_dish" = "Aggiungi come secondo piatto"; +"home_remove_second_dish" = "Rimuovi il secondo piatto"; diff --git a/MealMood/Resources/pt-BR.lproj/Localizable.strings b/MealMood/Resources/pt-BR.lproj/Localizable.strings index b19817b..ce3b97a 100644 --- a/MealMood/Resources/pt-BR.lproj/Localizable.strings +++ b/MealMood/Resources/pt-BR.lproj/Localizable.strings @@ -426,3 +426,5 @@ "dish_fixed_slot_desc" = "Sempre no mesmo dia e refeição no preenchimento automático"; "dish_fixed_slot_day" = "Dia"; "dish_fixed_slot_meal" = "Refeição"; +"home_pick_second_dish" = "Adicionar como segundo prato"; +"home_remove_second_dish" = "Remover o segundo prato"; diff --git a/MealMood/Services/ICloudSyncService.swift b/MealMood/Services/ICloudSyncService.swift index 030f0f5..16d0122 100644 --- a/MealMood/Services/ICloudSyncService.swift +++ b/MealMood/Services/ICloudSyncService.swift @@ -115,6 +115,7 @@ final class ICloudSyncService { dayOfWeek: $0.dayOfWeek, mealType: $0.mealType, dishId: $0.dishId, + secondaryDishId: $0.secondaryDishId, calendarEventId: $0.calendarEventId, isRuleOverridden: $0.isRuleOverridden ) @@ -210,6 +211,7 @@ final class ICloudSyncService { dayOfWeek: slotPayload.dayOfWeek, mealType: slotPayload.mealType, dishId: slotPayload.dishId, + secondaryDishId: slotPayload.secondaryDishId, calendarEventId: slotPayload.calendarEventId, isRuleOverridden: slotPayload.isRuleOverridden ) @@ -289,6 +291,8 @@ private struct MealSlotPayload: Codable { let dayOfWeek: Int let mealType: String let dishId: UUID? + // Optional so payloads written by older installs (no key) still decode. + var secondaryDishId: UUID? let calendarEventId: String? let isRuleOverridden: Bool } diff --git a/MealMood/Services/WidgetDataStore.swift b/MealMood/Services/WidgetDataStore.swift index 66ddb16..9221e42 100644 --- a/MealMood/Services/WidgetDataStore.swift +++ b/MealMood/Services/WidgetDataStore.swift @@ -26,10 +26,12 @@ enum WidgetDataStore { let appDayOfWeek = (weekday + 5) % 7 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 - }?.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( type: mealType.rawValue, label: String(localized: String.LocalizationValue(mealType.localizedKey)), diff --git a/MealMood/ViewModels/HomeViewModel.swift b/MealMood/ViewModels/HomeViewModel.swift index 42aed4f..609fed4 100644 --- a/MealMood/ViewModels/HomeViewModel.swift +++ b/MealMood/ViewModels/HomeViewModel.swift @@ -11,6 +11,7 @@ final class HomeViewModel: ObservableObject { private struct SlotSnapshot { let dishId: UUID? + let secondaryDishId: UUID? let isRuleOverridden: Bool let isEatingOut: Bool } @@ -94,6 +95,7 @@ final class HomeViewModel: ObservableObject { } slot.dishId = dish.id + slot.secondaryDishId = nil slot.isRuleOverridden = isOverride plan.updatedAt = Date() @@ -104,6 +106,25 @@ final class HomeViewModel: ObservableObject { 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) { captureUndoSnapshot(plan: plan) @@ -112,6 +133,7 @@ final class HomeViewModel: ObservableObject { slot.calendarEventId = nil } slot.dishId = nil + slot.secondaryDishId = nil slot.isRuleOverridden = false plan.updatedAt = Date() applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false) @@ -182,6 +204,7 @@ final class HomeViewModel: ObservableObject { slot.calendarEventId = nil } slot.dishId = nil + slot.secondaryDishId = nil slot.isRuleOverridden = false slot.isEatingOut = false } @@ -247,8 +270,11 @@ final class HomeViewModel: ObservableObject { } let targetDishId = targetSlot.dishId + let targetSecondaryId = targetSlot.secondaryDishId sourceSlot.dishId = targetDishId targetSlot.dishId = sourceDishId + targetSlot.secondaryDishId = sourceSlot.secondaryDishId + sourceSlot.secondaryDishId = targetSecondaryId sourceSlot.isRuleOverridden = false targetSlot.isRuleOverridden = false @@ -293,9 +319,12 @@ final class HomeViewModel: ObservableObject { let key = SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType) if let previousDishId = previousByKey[key]?.dishId, dishIds.contains(previousDishId) { slot.dishId = previousDishId + let previousSecondary = previousByKey[key]?.secondaryDishId + slot.secondaryDishId = previousSecondary.flatMap { dishIds.contains($0) ? $0 : nil } copiedCount += 1 } else { slot.dishId = nil + slot.secondaryDishId = nil } slot.isRuleOverridden = false updateRuleOverrideFlag(for: slot, plan: currentPlan, allTags: allTags, allDishes: allDishes) @@ -324,10 +353,12 @@ final class HomeViewModel: ObservableObject { if let snap = lastSlotsSnapshot[slot.id] { slot.dishId = snap.dishId + slot.secondaryDishId = snap.secondaryDishId slot.isRuleOverridden = snap.isRuleOverridden slot.isEatingOut = snap.isEatingOut } else { slot.dishId = nil + slot.secondaryDishId = nil slot.isRuleOverridden = false slot.isEatingOut = false } @@ -353,7 +384,7 @@ final class HomeViewModel: ObservableObject { guard !isApplyingUndo else { return } lastWeekStartSnapshot = plan.weekStartDate 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 }) hasUndoSnapshot = true } @@ -463,6 +494,7 @@ final class HomeViewModel: ObservableObject { slot.calendarEventId = nil } slot.dishId = nil + slot.secondaryDishId = nil slot.isRuleOverridden = false slot.isEatingOut = true plan.updatedAt = Date() diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index 55ade77..758db10 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -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) { diff --git a/MealMood/Views/Home/WeekCalendarView.swift b/MealMood/Views/Home/WeekCalendarView.swift index 90b017d..52f4dc7 100644 --- a/MealMood/Views/Home/WeekCalendarView.swift +++ b/MealMood/Views/Home/WeekCalendarView.swift @@ -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 ) diff --git a/MealMood/Views/Home/WeekPlanShareView.swift b/MealMood/Views/Home/WeekPlanShareView.swift index 54b5bf5..80ee219 100644 --- a/MealMood/Views/Home/WeekPlanShareView.swift +++ b/MealMood/Views/Home/WeekPlanShareView.swift @@ -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 } diff --git a/MealMood/Views/Shopping/ShoppingListView.swift b/MealMood/Views/Shopping/ShoppingListView.swift index 67799c4..e699408 100644 --- a/MealMood/Views/Shopping/ShoppingListView.swift +++ b/MealMood/Views/Shopping/ShoppingListView.swift @@ -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 } diff --git a/MealMood/Views/Stats/StatsView.swift b/MealMood/Views/Stats/StatsView.swift index 208d504..b8396d9 100644 --- a/MealMood/Views/Stats/StatsView.swift +++ b/MealMood/Views/Stats/StatsView.swift @@ -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