diff --git a/MealMood/Components/EmptySlotView.swift b/MealMood/Components/EmptySlotView.swift index f41df1f..6bc9318 100644 --- a/MealMood/Components/EmptySlotView.swift +++ b/MealMood/Components/EmptySlotView.swift @@ -71,3 +71,32 @@ struct EatingOutSlotView: View { ) } } + +/// "Not planning this meal" — intentionally blank, resolved slot. +struct SkippedSlotView: View { + let mealType: MealType + + var body: some View { + VStack(spacing: 6) { + Image(systemName: "minus.circle") + .font(.system(size: 20)) + .foregroundColor(Color(hex: "#B9B4AE")) + + Text("slot_skipped") + .font(.mealMoodCaption) + .foregroundColor(Color(hex: "#8E8B86")) + } + .frame(maxWidth: .infinity, minHeight: 80) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(hex: "#F5F3F0")) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder( + style: StrokeStyle(lineWidth: 2, dash: [5]) + ) + .foregroundColor(Color(hex: "#CFCAC4")) + ) + ) + } +} diff --git a/MealMood/Models/MealSlot.swift b/MealMood/Models/MealSlot.swift index 94e392c..4b79a52 100644 --- a/MealMood/Models/MealSlot.swift +++ b/MealMood/Models/MealSlot.swift @@ -14,6 +14,9 @@ final class MealSlot { var calendarEventId: String? var isRuleOverridden: Bool = false var isEatingOut: Bool = false + /// "Don't plan this meal": counts as resolved (like eating out) but shows + /// as intentionally blank — autocomplete leaves it alone. + var isSkipped: Bool = false var weekPlan: WeekPlan? @@ -25,7 +28,8 @@ final class MealSlot { secondaryDishId: UUID? = nil, calendarEventId: String? = nil, isRuleOverridden: Bool = false, - isEatingOut: Bool = false + isEatingOut: Bool = false, + isSkipped: Bool = false ) { self.id = id self.dayOfWeek = dayOfWeek @@ -35,6 +39,7 @@ final class MealSlot { self.calendarEventId = calendarEventId self.isRuleOverridden = isRuleOverridden self.isEatingOut = isEatingOut + self.isSkipped = isSkipped } var mealTypeEnum: MealType { diff --git a/MealMood/Models/ShoppingItem.swift b/MealMood/Models/ShoppingItem.swift index 04a8dfe..4fccd2f 100644 --- a/MealMood/Models/ShoppingItem.swift +++ b/MealMood/Models/ShoppingItem.swift @@ -12,6 +12,9 @@ final class ShoppingItem { var title: String = "" var dishId: UUID? var isChecked: Bool = false + /// "Already have it at home": kept out of the active list and the export, + /// but not deleted — reconcile would recreate a deleted dish-derived line. + var isDismissed: Bool = false var sortOrder: Int = 0 var createdAt: Date = Date() @@ -21,6 +24,7 @@ final class ShoppingItem { title: String, dishId: UUID? = nil, isChecked: Bool = false, + isDismissed: Bool = false, sortOrder: Int = 0, createdAt: Date = Date() ) { @@ -29,6 +33,7 @@ final class ShoppingItem { self.title = title self.dishId = dishId self.isChecked = isChecked + self.isDismissed = isDismissed self.sortOrder = sortOrder self.createdAt = createdAt } diff --git a/MealMood/Resources/de.lproj/Localizable.strings b/MealMood/Resources/de.lproj/Localizable.strings index 52d3385..bd1bfe0 100644 --- a/MealMood/Resources/de.lproj/Localizable.strings +++ b/MealMood/Resources/de.lproj/Localizable.strings @@ -437,3 +437,9 @@ "share_export_style_minimal" = "Minimal (Druck)"; "share_export_copy_text" = "Als Text kopieren (WhatsApp)"; "share_text_copied" = "Kopiert!"; +"home_mark_skipped" = "Diese Mahlzeit überspringen"; +"slot_skipped" = "Nicht geplant"; +"toast_meal_skipped" = "Mahlzeit übersprungen"; +"shopping_exclude_dish" = "Schon gekocht"; +"shopping_have_it" = "Hab ich"; +"shopping_at_home_section" = "Schon zu Hause"; diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index 03cf345..c3d9871 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -437,3 +437,9 @@ "share_export_style_minimal" = "Minimal (print)"; "share_export_copy_text" = "Copy as text (WhatsApp)"; "share_text_copied" = "Copied!"; +"home_mark_skipped" = "Skip this meal"; +"slot_skipped" = "Not planned"; +"toast_meal_skipped" = "Meal skipped"; +"shopping_exclude_dish" = "Already made"; +"shopping_have_it" = "Have it"; +"shopping_at_home_section" = "Already at home"; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index e5f3d1d..1443fde 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -437,3 +437,9 @@ "share_export_style_minimal" = "Minimal (imprimir)"; "share_export_copy_text" = "Copiar como texto (WhatsApp)"; "share_text_copied" = "¡Copiado!"; +"home_mark_skipped" = "No planificar esta comida"; +"slot_skipped" = "Sin planificar"; +"toast_meal_skipped" = "Comida sin planificar"; +"shopping_exclude_dish" = "Ya está hecho"; +"shopping_have_it" = "Ya lo tengo"; +"shopping_at_home_section" = "Ya en casa"; diff --git a/MealMood/Resources/fr.lproj/Localizable.strings b/MealMood/Resources/fr.lproj/Localizable.strings index bd9bc2b..ce4dd7f 100644 --- a/MealMood/Resources/fr.lproj/Localizable.strings +++ b/MealMood/Resources/fr.lproj/Localizable.strings @@ -437,3 +437,9 @@ "share_export_style_minimal" = "Minimal (impression)"; "share_export_copy_text" = "Copier en texte (WhatsApp)"; "share_text_copied" = "Copié !"; +"home_mark_skipped" = "Ne pas planifier ce repas"; +"slot_skipped" = "Non planifié"; +"toast_meal_skipped" = "Repas non planifié"; +"shopping_exclude_dish" = "Déjà préparé"; +"shopping_have_it" = "Je l'ai déjà"; +"shopping_at_home_section" = "Déjà à la maison"; diff --git a/MealMood/Resources/it.lproj/Localizable.strings b/MealMood/Resources/it.lproj/Localizable.strings index 7d0f124..1115fb6 100644 --- a/MealMood/Resources/it.lproj/Localizable.strings +++ b/MealMood/Resources/it.lproj/Localizable.strings @@ -437,3 +437,9 @@ "share_export_style_minimal" = "Minimal (stampa)"; "share_export_copy_text" = "Copia come testo (WhatsApp)"; "share_text_copied" = "Copiato!"; +"home_mark_skipped" = "Non pianificare questo pasto"; +"slot_skipped" = "Non pianificato"; +"toast_meal_skipped" = "Pasto non pianificato"; +"shopping_exclude_dish" = "Già pronto"; +"shopping_have_it" = "Ce l'ho già"; +"shopping_at_home_section" = "Già a casa"; diff --git a/MealMood/Resources/pt-BR.lproj/Localizable.strings b/MealMood/Resources/pt-BR.lproj/Localizable.strings index ba45302..5a10be7 100644 --- a/MealMood/Resources/pt-BR.lproj/Localizable.strings +++ b/MealMood/Resources/pt-BR.lproj/Localizable.strings @@ -437,3 +437,9 @@ "share_export_style_minimal" = "Minimal (imprimir)"; "share_export_copy_text" = "Copiar como texto (WhatsApp)"; "share_text_copied" = "Copiado!"; +"home_mark_skipped" = "Não planejar esta refeição"; +"slot_skipped" = "Sem planejar"; +"toast_meal_skipped" = "Refeição sem planejar"; +"shopping_exclude_dish" = "Já está pronto"; +"shopping_have_it" = "Já tenho"; +"shopping_at_home_section" = "Já em casa"; diff --git a/MealMood/Services/AutocompleteEngine.swift b/MealMood/Services/AutocompleteEngine.swift index 7144fc2..b9cc113 100644 --- a/MealMood/Services/AutocompleteEngine.swift +++ b/MealMood/Services/AutocompleteEngine.swift @@ -31,7 +31,7 @@ struct AutocompleteEngine { var filledSlots: [(slotId: UUID, dishId: UUID)] = [] var unfilledCount = 0 - var pendingSlots = emptySlots.filter { !$0.isEatingOut } + var pendingSlots = emptySlots.filter { !$0.isEatingOut && !$0.isSkipped } // Fixed-slot rules win outright: a dish pinned to a weekday+meal fills // that slot before any scoring. Runs before the MRV loop so the pinned diff --git a/MealMood/Services/ICloudSyncService.swift b/MealMood/Services/ICloudSyncService.swift index 16d0122..106f42f 100644 --- a/MealMood/Services/ICloudSyncService.swift +++ b/MealMood/Services/ICloudSyncService.swift @@ -117,7 +117,9 @@ final class ICloudSyncService { dishId: $0.dishId, secondaryDishId: $0.secondaryDishId, calendarEventId: $0.calendarEventId, - isRuleOverridden: $0.isRuleOverridden + isRuleOverridden: $0.isRuleOverridden, + isEatingOut: $0.isEatingOut, + isSkipped: $0.isSkipped ) } ) @@ -213,7 +215,9 @@ final class ICloudSyncService { dishId: slotPayload.dishId, secondaryDishId: slotPayload.secondaryDishId, calendarEventId: slotPayload.calendarEventId, - isRuleOverridden: slotPayload.isRuleOverridden + isRuleOverridden: slotPayload.isRuleOverridden, + isEatingOut: slotPayload.isEatingOut ?? false, + isSkipped: slotPayload.isSkipped ?? false ) slot.weekPlan = plan plan.slotList.append(slot) @@ -291,8 +295,10 @@ private struct MealSlotPayload: Codable { let dayOfWeek: Int let mealType: String let dishId: UUID? - // Optional so payloads written by older installs (no key) still decode. + // Optionals so payloads written by older installs (no key) still decode. var secondaryDishId: UUID? let calendarEventId: String? let isRuleOverridden: Bool + var isEatingOut: Bool? + var isSkipped: Bool? } diff --git a/MealMood/Services/NotificationService.swift b/MealMood/Services/NotificationService.swift index ec69aaf..a006595 100644 --- a/MealMood/Services/NotificationService.swift +++ b/MealMood/Services/NotificationService.swift @@ -64,7 +64,7 @@ final class NotificationService { let center = UNUserNotificationCenter.current() let identifier = "mealmood.next-week-planning" - let isComplete = nextWeekPlan?.slotList.allSatisfy { $0.dishId != nil || $0.isEatingOut } ?? false + let isComplete = nextWeekPlan?.slotList.allSatisfy { $0.dishId != nil || $0.isEatingOut || $0.isSkipped } ?? false if isComplete { center.removePendingNotificationRequests(withIdentifiers: [identifier]) } else { @@ -94,7 +94,7 @@ final class NotificationService { let identifier = "mealmood.sunday-planning" // Cancel if next week is already planned - let hasAnySlot = nextWeekPlan?.slotList.contains { $0.dishId != nil || $0.isEatingOut } ?? false + let hasAnySlot = nextWeekPlan?.slotList.contains { $0.dishId != nil || $0.isEatingOut || $0.isSkipped } ?? false if hasAnySlot { center.removePendingNotificationRequests(withIdentifiers: [identifier]) return diff --git a/MealMood/ViewModels/HomeViewModel.swift b/MealMood/ViewModels/HomeViewModel.swift index 609fed4..f9bafd7 100644 --- a/MealMood/ViewModels/HomeViewModel.swift +++ b/MealMood/ViewModels/HomeViewModel.swift @@ -14,6 +14,7 @@ final class HomeViewModel: ObservableObject { let secondaryDishId: UUID? let isRuleOverridden: Bool let isEatingOut: Bool + let isSkipped: Bool } @Published var currentWeekStart: Date @@ -150,7 +151,7 @@ final class HomeViewModel: ObservableObject { .sorted { $0.weekStartDate > $1.weekStartDate } .prefix(12) - let emptySlots = plan.slotList.filter { $0.dishId == nil && !$0.isEatingOut } + let emptySlots = plan.slotList.filter { $0.dishId == nil && !$0.isEatingOut && !$0.isSkipped } let result = AutocompleteEngine.autocomplete( emptySlots: emptySlots, currentPlan: plan, @@ -207,6 +208,7 @@ final class HomeViewModel: ObservableObject { slot.secondaryDishId = nil slot.isRuleOverridden = false slot.isEatingOut = false + slot.isSkipped = false } plan.updatedAt = Date() AnalyticsService.logWeekReset() @@ -356,11 +358,13 @@ final class HomeViewModel: ObservableObject { slot.secondaryDishId = snap.secondaryDishId slot.isRuleOverridden = snap.isRuleOverridden slot.isEatingOut = snap.isEatingOut + slot.isSkipped = snap.isSkipped } else { slot.dishId = nil slot.secondaryDishId = nil slot.isRuleOverridden = false slot.isEatingOut = false + slot.isSkipped = false } } @@ -384,7 +388,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, secondaryDishId: slot.secondaryDishId, isRuleOverridden: slot.isRuleOverridden, isEatingOut: slot.isEatingOut)) + (slot.id, SlotSnapshot(dishId: slot.dishId, secondaryDishId: slot.secondaryDishId, isRuleOverridden: slot.isRuleOverridden, isEatingOut: slot.isEatingOut, isSkipped: slot.isSkipped)) }, uniquingKeysWith: { first, _ in first }) hasUndoSnapshot = true } @@ -397,7 +401,7 @@ final class HomeViewModel: ObservableObject { private func firstFreeSlot(in plan: WeekPlan) -> MealSlot? { plan.slotList - .filter { $0.dishId == nil && !$0.isEatingOut } + .filter { $0.dishId == nil && !$0.isEatingOut && !$0.isSkipped } .sorted { lhs, rhs in if lhs.dayOfWeek != rhs.dayOfWeek { return lhs.dayOfWeek < rhs.dayOfWeek @@ -416,7 +420,7 @@ final class HomeViewModel: ObservableObject { switch settings.syncModeEnum { case .weekComplete: - if plan.slotList.allSatisfy({ $0.dishId != nil || $0.isEatingOut }) { + if plan.slotList.allSatisfy({ $0.dishId != nil || $0.isEatingOut || $0.isSkipped }) { let descriptor = FetchDescriptor() let dishes = (try? plan.modelContext?.fetch(descriptor)) ?? [] syncAllAssignedSlotsToCalendar(plan: plan, dishes: dishes, settings: settings) @@ -497,6 +501,7 @@ final class HomeViewModel: ObservableObject { slot.secondaryDishId = nil slot.isRuleOverridden = false slot.isEatingOut = true + slot.isSkipped = false plan.updatedAt = Date() applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false) HapticManager.shared.impact(style: .medium) @@ -510,6 +515,33 @@ final class HomeViewModel: ObservableObject { HapticManager.shared.impact(style: .light) } + /// "Don't plan this meal": resolves the slot without a dish, so the week + /// can complete and autocomplete leaves it alone. + func markSkipped(slot: MealSlot, plan: WeekPlan, settings: AppSettings) { + captureUndoSnapshot(plan: plan) + if let eventId = slot.calendarEventId { + CalendarService.shared.deleteEvent(eventId: eventId) + slot.calendarEventId = nil + } + slot.dishId = nil + slot.secondaryDishId = nil + slot.isRuleOverridden = false + slot.isEatingOut = false + slot.isSkipped = true + plan.updatedAt = Date() + applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false) + AnalyticsService.logEvent("meal_skipped") + HapticManager.shared.impact(style: .medium) + showToastMessage(localizedString("toast_meal_skipped", language: settings.languageEnum.resolved())) + } + + func unmarkSkipped(slot: MealSlot, plan: WeekPlan, settings: AppSettings) { + captureUndoSnapshot(plan: plan) + slot.isSkipped = false + plan.updatedAt = Date() + HapticManager.shared.impact(style: .light) + } + func acknowledgeViolation(slot: MealSlot, plan: WeekPlan) { slot.isRuleOverridden = false plan.updatedAt = Date() diff --git a/MealMood/ViewModels/OnboardingViewModel.swift b/MealMood/ViewModels/OnboardingViewModel.swift index 4494b08..5e53080 100644 --- a/MealMood/ViewModels/OnboardingViewModel.swift +++ b/MealMood/ViewModels/OnboardingViewModel.swift @@ -217,7 +217,7 @@ final class OnboardingViewModel: ObservableObject { let plans = (try? context.fetch(FetchDescriptor())) ?? [] guard let plan = plans.first(where: { $0.weekStartDate == weekStart }) else { return 0 } - let emptySlots = plan.slotList.filter { $0.dishId == nil && !$0.isEatingOut } + let emptySlots = plan.slotList.filter { $0.dishId == nil && !$0.isEatingOut && !$0.isSkipped } guard !emptySlots.isEmpty else { return 0 } let result = AutocompleteEngine.autocomplete( diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index 9704e72..3c923e4 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -202,7 +202,7 @@ struct HomeView: View { HStack(spacing: 10) { if let settings = settings, let plan = fetchWeekPlan(for: viewModel.currentWeekStart) { - if plan.slotList.contains(where: { $0.dishId != nil || $0.isEatingOut }) { + if plan.slotList.contains(where: { $0.dishId != nil || $0.isEatingOut || $0.isSkipped }) { Button { requestWeekShare(plan: plan, settings: settings) } label: { @@ -337,8 +337,8 @@ struct HomeView: View { calendarResizeHandle(availableHeight: contentGeo.size.height) } - let filledCount = plan.slotList.filter { $0.dishId != nil || $0.isEatingOut }.count - let emptyCount = plan.slotList.filter { $0.dishId == nil && !$0.isEatingOut }.count + let filledCount = plan.slotList.filter { $0.dishId != nil || $0.isEatingOut || $0.isSkipped }.count + let emptyCount = plan.slotList.filter { $0.dishId == nil && !$0.isEatingOut && !$0.isSkipped }.count // Only surface the export banner once the week is actually // complete (no empty slots) — and let the user dismiss it. if filledCount > 0 && emptyCount == 0 && !isExportCalloutDismissed(plan) { @@ -537,6 +537,14 @@ struct HomeView: View { } selectedEmptySlotId = nil viewModel.markEatingOut(slot: freshSlot, plan: plan, settings: settings) + }, + onSkip: { + guard let freshSlot = plan.slotList.first(where: { $0.id == slotId }) else { + selectedEmptySlotId = nil + return + } + selectedEmptySlotId = nil + viewModel.markSkipped(slot: freshSlot, plan: plan, settings: settings) } ) } @@ -669,6 +677,7 @@ struct HomeView: View { let onPickDish: (Dish) -> Void let onCreateDish: () -> Void var onEatingOut: (() -> Void)? = nil + var onSkip: (() -> 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 @@ -721,6 +730,18 @@ struct HomeView: View { .listRowBackground(Color(hex: "#EEF8F5")) } + if let onSkip, searchText.isEmpty { + Button { + dismiss() + onSkip() + } label: { + Label("home_mark_skipped", systemImage: "minus.circle") + .font(.mealMoodBody) + .foregroundColor(Color(hex: "#8E8B86")) + } + .listRowBackground(Color(hex: "#F5F3F0")) + } + if onPickSecondDish != nil, searchText.isEmpty { Toggle(isOn: $asSecondDish) { Label("home_pick_second_dish", systemImage: "plus.square.on.square") @@ -898,7 +919,7 @@ struct HomeView: View { } private func isWeekComplete(plan: WeekPlan) -> Bool { - plan.slotList.allSatisfy { $0.dishId != nil || $0.isEatingOut } + plan.slotList.allSatisfy { $0.dishId != nil || $0.isEatingOut || $0.isSkipped } } private var dishUsageCounts: [UUID: Int] { @@ -1214,7 +1235,7 @@ struct HomeView: View { // instead of being asked. Reuses the home's autoComplete choreography. if UserDefaults.standard.bool(forKey: OnboardingViewModel.pendingAutoFillOnLaunchKey) { UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingAutoFillOnLaunchKey) - if !dishes.isEmpty && plan.slotList.contains(where: { $0.dishId == nil && !$0.isEatingOut }) { + if !dishes.isEmpty && plan.slotList.contains(where: { $0.dishId == nil && !$0.isEatingOut && !$0.isSkipped }) { viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings, allPlans: weekPlans) } schedulePostOnboardingPremiumPromptIfNeeded(settings: settings) diff --git a/MealMood/Views/Home/WeekCalendarView.swift b/MealMood/Views/Home/WeekCalendarView.swift index 52f4dc7..2f52567 100644 --- a/MealMood/Views/Home/WeekCalendarView.swift +++ b/MealMood/Views/Home/WeekCalendarView.swift @@ -216,7 +216,8 @@ struct WeekCalendarView: View { dishId: preferred.dishId, secondaryDishId: preferred.secondaryDishId, isRuleOverridden: preferred.isRuleOverridden, - isEatingOut: preferred.isEatingOut + isEatingOut: preferred.isEatingOut, + isSkipped: preferred.isSkipped ) } @@ -285,6 +286,14 @@ struct WeekCalendarView: View { onTapFilledSlot?(live) } .draggable("slot:\(slot.slotId.uuidString)") + } else if let slot = slot, slot.isSkipped { + SkippedSlotView(mealType: mealType) + .contentShape(Rectangle()) + .onTapGesture { + guard viewModel.canEditCurrentWeek else { return } + guard let live = liveSlot(for: slot) else { return } + viewModel.unmarkSkipped(slot: live, plan: plan, settings: settings) + } } else if let slot = slot, slot.isEatingOut { EatingOutSlotView(mealType: mealType) .contentShape(Rectangle()) @@ -326,6 +335,7 @@ struct WeekCalendarView: View { let secondaryDishId: UUID? let isRuleOverridden: Bool let isEatingOut: Bool + let isSkipped: Bool } private var dishesSnapshotKey: String { @@ -375,7 +385,8 @@ struct WeekCalendarView: View { dishId: preferred.dishId, secondaryDishId: preferred.secondaryDishId, isRuleOverridden: preferred.isRuleOverridden, - isEatingOut: preferred.isEatingOut + isEatingOut: preferred.isEatingOut, + isSkipped: preferred.isSkipped ) } diff --git a/MealMood/Views/Home/WeekPlanShareView.swift b/MealMood/Views/Home/WeekPlanShareView.swift index 90dc9a6..9c6266b 100644 --- a/MealMood/Views/Home/WeekPlanShareView.swift +++ b/MealMood/Views/Home/WeekPlanShareView.swift @@ -793,6 +793,7 @@ struct WeekPlanShareView: View { let slot = plan.slotList.first { $0.dayOfWeek == day && $0.mealType == mealType.rawValue } guard let slot else { return String(localized: "share_slot_empty") } if slot.isEatingOut { return String(localized: "slot_eating_out") } + if slot.isSkipped { return "—" } guard let dishId = slot.dishId, let dish = dishes.first(where: { $0.id == dishId }) else { return String(localized: "share_slot_empty") } diff --git a/MealMood/Views/Shopping/ShoppingListView.swift b/MealMood/Views/Shopping/ShoppingListView.swift index e699408..b273457 100644 --- a/MealMood/Views/Shopping/ShoppingListView.swift +++ b/MealMood/Views/Shopping/ShoppingListView.swift @@ -49,11 +49,15 @@ struct ShoppingListView: View { } private var freeTextItems: [ShoppingItem] { - items.filter { $0.dishId == nil } + 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 } + items.filter { $0.dishId == dish.id && !$0.isDismissed } } var body: some View { @@ -66,10 +70,24 @@ struct ShoppingListView: View { ForEach(plannedDishes, id: \.id) { dish in let dishItems = items(for: dish) if !dishItems.isEmpty { - Section(dish.name) { + 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) + } } } } @@ -82,6 +100,28 @@ struct ShoppingListView: View { } } + 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) @@ -187,6 +227,23 @@ struct ShoppingListView: View { 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 @@ -274,7 +331,7 @@ struct ShoppingListView: View { private var exportText: String { ShoppingListService.exportText( - items: items, + items: items.filter { !$0.isDismissed }, header: String(localized: "shopping_share_header") ) } diff --git a/MealMood/Views/Stats/StatsView.swift b/MealMood/Views/Stats/StatsView.swift index b8396d9..a0d8c92 100644 --- a/MealMood/Views/Stats/StatsView.swift +++ b/MealMood/Views/Stats/StatsView.swift @@ -137,7 +137,7 @@ struct StatsView: View { private var completeWeeks: Int { weekPlans.filter { plan in - !plan.slotList.isEmpty && plan.slotList.allSatisfy { $0.dishId != nil || $0.isEatingOut } + !plan.slotList.isEmpty && plan.slotList.allSatisfy { $0.dishId != nil || $0.isEatingOut || $0.isSkipped } }.count }