huecos sin planificar y exclusiones en la lista de la compra
- MealSlot.isSkipped: opcion "No planificar esta comida" en el picker de hueco vacio — cuenta como resuelto (semana completa, notificaciones, stats) y el autocompletado lo respeta; vista gris con guion, tap para desmarcar; viaja en undo, snapshot KV (junto con isEatingOut, que faltaba en el payload) y exports (— en imagen, omitido en texto) - ShoppingItem.isDismissed: "Ya lo tengo" por ingrediente (swipe izquierdo) y "Ya esta hecho" por plato entero (boton en la cabecera de su seccion); van a la seccion "Ya en casa" con restauracion de un tap, y quedan fuera del export de texto. No se borran porque reconcile los recrearia - Esquema CloudKit Development: CD_isSkipped, CD_isDismissed y el record type CD_ShoppingItem entero, que no existia — la lista de la compra no estaba sincronizando entre dispositivos (mismo bug que photoData) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
This commit is contained in:
@@ -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"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ final class MealSlot {
|
|||||||
var calendarEventId: String?
|
var calendarEventId: String?
|
||||||
var isRuleOverridden: Bool = false
|
var isRuleOverridden: Bool = false
|
||||||
var isEatingOut: 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?
|
var weekPlan: WeekPlan?
|
||||||
|
|
||||||
@@ -25,7 +28,8 @@ final class MealSlot {
|
|||||||
secondaryDishId: UUID? = nil,
|
secondaryDishId: UUID? = nil,
|
||||||
calendarEventId: String? = nil,
|
calendarEventId: String? = nil,
|
||||||
isRuleOverridden: Bool = false,
|
isRuleOverridden: Bool = false,
|
||||||
isEatingOut: Bool = false
|
isEatingOut: Bool = false,
|
||||||
|
isSkipped: Bool = false
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.dayOfWeek = dayOfWeek
|
self.dayOfWeek = dayOfWeek
|
||||||
@@ -35,6 +39,7 @@ final class MealSlot {
|
|||||||
self.calendarEventId = calendarEventId
|
self.calendarEventId = calendarEventId
|
||||||
self.isRuleOverridden = isRuleOverridden
|
self.isRuleOverridden = isRuleOverridden
|
||||||
self.isEatingOut = isEatingOut
|
self.isEatingOut = isEatingOut
|
||||||
|
self.isSkipped = isSkipped
|
||||||
}
|
}
|
||||||
|
|
||||||
var mealTypeEnum: MealType {
|
var mealTypeEnum: MealType {
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ final class ShoppingItem {
|
|||||||
var title: String = ""
|
var title: String = ""
|
||||||
var dishId: UUID?
|
var dishId: UUID?
|
||||||
var isChecked: Bool = false
|
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 sortOrder: Int = 0
|
||||||
var createdAt: Date = Date()
|
var createdAt: Date = Date()
|
||||||
|
|
||||||
@@ -21,6 +24,7 @@ final class ShoppingItem {
|
|||||||
title: String,
|
title: String,
|
||||||
dishId: UUID? = nil,
|
dishId: UUID? = nil,
|
||||||
isChecked: Bool = false,
|
isChecked: Bool = false,
|
||||||
|
isDismissed: Bool = false,
|
||||||
sortOrder: Int = 0,
|
sortOrder: Int = 0,
|
||||||
createdAt: Date = Date()
|
createdAt: Date = Date()
|
||||||
) {
|
) {
|
||||||
@@ -29,6 +33,7 @@ final class ShoppingItem {
|
|||||||
self.title = title
|
self.title = title
|
||||||
self.dishId = dishId
|
self.dishId = dishId
|
||||||
self.isChecked = isChecked
|
self.isChecked = isChecked
|
||||||
|
self.isDismissed = isDismissed
|
||||||
self.sortOrder = sortOrder
|
self.sortOrder = sortOrder
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -437,3 +437,9 @@
|
|||||||
"share_export_style_minimal" = "Minimal (Druck)";
|
"share_export_style_minimal" = "Minimal (Druck)";
|
||||||
"share_export_copy_text" = "Als Text kopieren (WhatsApp)";
|
"share_export_copy_text" = "Als Text kopieren (WhatsApp)";
|
||||||
"share_text_copied" = "Kopiert!";
|
"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";
|
||||||
|
|||||||
@@ -437,3 +437,9 @@
|
|||||||
"share_export_style_minimal" = "Minimal (print)";
|
"share_export_style_minimal" = "Minimal (print)";
|
||||||
"share_export_copy_text" = "Copy as text (WhatsApp)";
|
"share_export_copy_text" = "Copy as text (WhatsApp)";
|
||||||
"share_text_copied" = "Copied!";
|
"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";
|
||||||
|
|||||||
@@ -437,3 +437,9 @@
|
|||||||
"share_export_style_minimal" = "Minimal (imprimir)";
|
"share_export_style_minimal" = "Minimal (imprimir)";
|
||||||
"share_export_copy_text" = "Copiar como texto (WhatsApp)";
|
"share_export_copy_text" = "Copiar como texto (WhatsApp)";
|
||||||
"share_text_copied" = "¡Copiado!";
|
"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";
|
||||||
|
|||||||
@@ -437,3 +437,9 @@
|
|||||||
"share_export_style_minimal" = "Minimal (impression)";
|
"share_export_style_minimal" = "Minimal (impression)";
|
||||||
"share_export_copy_text" = "Copier en texte (WhatsApp)";
|
"share_export_copy_text" = "Copier en texte (WhatsApp)";
|
||||||
"share_text_copied" = "Copié !";
|
"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";
|
||||||
|
|||||||
@@ -437,3 +437,9 @@
|
|||||||
"share_export_style_minimal" = "Minimal (stampa)";
|
"share_export_style_minimal" = "Minimal (stampa)";
|
||||||
"share_export_copy_text" = "Copia come testo (WhatsApp)";
|
"share_export_copy_text" = "Copia come testo (WhatsApp)";
|
||||||
"share_text_copied" = "Copiato!";
|
"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";
|
||||||
|
|||||||
@@ -437,3 +437,9 @@
|
|||||||
"share_export_style_minimal" = "Minimal (imprimir)";
|
"share_export_style_minimal" = "Minimal (imprimir)";
|
||||||
"share_export_copy_text" = "Copiar como texto (WhatsApp)";
|
"share_export_copy_text" = "Copiar como texto (WhatsApp)";
|
||||||
"share_text_copied" = "Copiado!";
|
"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";
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ struct AutocompleteEngine {
|
|||||||
|
|
||||||
var filledSlots: [(slotId: UUID, dishId: UUID)] = []
|
var filledSlots: [(slotId: UUID, dishId: UUID)] = []
|
||||||
var unfilledCount = 0
|
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
|
// 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
|
// that slot before any scoring. Runs before the MRV loop so the pinned
|
||||||
|
|||||||
@@ -117,7 +117,9 @@ final class ICloudSyncService {
|
|||||||
dishId: $0.dishId,
|
dishId: $0.dishId,
|
||||||
secondaryDishId: $0.secondaryDishId,
|
secondaryDishId: $0.secondaryDishId,
|
||||||
calendarEventId: $0.calendarEventId,
|
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,
|
dishId: slotPayload.dishId,
|
||||||
secondaryDishId: slotPayload.secondaryDishId,
|
secondaryDishId: slotPayload.secondaryDishId,
|
||||||
calendarEventId: slotPayload.calendarEventId,
|
calendarEventId: slotPayload.calendarEventId,
|
||||||
isRuleOverridden: slotPayload.isRuleOverridden
|
isRuleOverridden: slotPayload.isRuleOverridden,
|
||||||
|
isEatingOut: slotPayload.isEatingOut ?? false,
|
||||||
|
isSkipped: slotPayload.isSkipped ?? false
|
||||||
)
|
)
|
||||||
slot.weekPlan = plan
|
slot.weekPlan = plan
|
||||||
plan.slotList.append(slot)
|
plan.slotList.append(slot)
|
||||||
@@ -291,8 +295,10 @@ 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.
|
// Optionals so payloads written by older installs (no key) still decode.
|
||||||
var secondaryDishId: UUID?
|
var secondaryDishId: UUID?
|
||||||
let calendarEventId: String?
|
let calendarEventId: String?
|
||||||
let isRuleOverridden: Bool
|
let isRuleOverridden: Bool
|
||||||
|
var isEatingOut: Bool?
|
||||||
|
var isSkipped: Bool?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ final class NotificationService {
|
|||||||
let center = UNUserNotificationCenter.current()
|
let center = UNUserNotificationCenter.current()
|
||||||
let identifier = "mealmood.next-week-planning"
|
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 {
|
if isComplete {
|
||||||
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
||||||
} else {
|
} else {
|
||||||
@@ -94,7 +94,7 @@ final class NotificationService {
|
|||||||
let identifier = "mealmood.sunday-planning"
|
let identifier = "mealmood.sunday-planning"
|
||||||
|
|
||||||
// Cancel if next week is already planned
|
// 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 {
|
if hasAnySlot {
|
||||||
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
let secondaryDishId: UUID?
|
let secondaryDishId: UUID?
|
||||||
let isRuleOverridden: Bool
|
let isRuleOverridden: Bool
|
||||||
let isEatingOut: Bool
|
let isEatingOut: Bool
|
||||||
|
let isSkipped: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published var currentWeekStart: Date
|
@Published var currentWeekStart: Date
|
||||||
@@ -150,7 +151,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
.sorted { $0.weekStartDate > $1.weekStartDate }
|
.sorted { $0.weekStartDate > $1.weekStartDate }
|
||||||
.prefix(12)
|
.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(
|
let result = AutocompleteEngine.autocomplete(
|
||||||
emptySlots: emptySlots,
|
emptySlots: emptySlots,
|
||||||
currentPlan: plan,
|
currentPlan: plan,
|
||||||
@@ -207,6 +208,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
slot.secondaryDishId = nil
|
slot.secondaryDishId = nil
|
||||||
slot.isRuleOverridden = false
|
slot.isRuleOverridden = false
|
||||||
slot.isEatingOut = false
|
slot.isEatingOut = false
|
||||||
|
slot.isSkipped = false
|
||||||
}
|
}
|
||||||
plan.updatedAt = Date()
|
plan.updatedAt = Date()
|
||||||
AnalyticsService.logWeekReset()
|
AnalyticsService.logWeekReset()
|
||||||
@@ -356,11 +358,13 @@ final class HomeViewModel: ObservableObject {
|
|||||||
slot.secondaryDishId = snap.secondaryDishId
|
slot.secondaryDishId = snap.secondaryDishId
|
||||||
slot.isRuleOverridden = snap.isRuleOverridden
|
slot.isRuleOverridden = snap.isRuleOverridden
|
||||||
slot.isEatingOut = snap.isEatingOut
|
slot.isEatingOut = snap.isEatingOut
|
||||||
|
slot.isSkipped = snap.isSkipped
|
||||||
} else {
|
} else {
|
||||||
slot.dishId = nil
|
slot.dishId = nil
|
||||||
slot.secondaryDishId = nil
|
slot.secondaryDishId = nil
|
||||||
slot.isRuleOverridden = false
|
slot.isRuleOverridden = false
|
||||||
slot.isEatingOut = false
|
slot.isEatingOut = false
|
||||||
|
slot.isSkipped = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +388,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, 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 })
|
}, uniquingKeysWith: { first, _ in first })
|
||||||
hasUndoSnapshot = true
|
hasUndoSnapshot = true
|
||||||
}
|
}
|
||||||
@@ -397,7 +401,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
|
|
||||||
private func firstFreeSlot(in plan: WeekPlan) -> MealSlot? {
|
private func firstFreeSlot(in plan: WeekPlan) -> MealSlot? {
|
||||||
plan.slotList
|
plan.slotList
|
||||||
.filter { $0.dishId == nil && !$0.isEatingOut }
|
.filter { $0.dishId == nil && !$0.isEatingOut && !$0.isSkipped }
|
||||||
.sorted { lhs, rhs in
|
.sorted { lhs, rhs in
|
||||||
if lhs.dayOfWeek != rhs.dayOfWeek {
|
if lhs.dayOfWeek != rhs.dayOfWeek {
|
||||||
return lhs.dayOfWeek < rhs.dayOfWeek
|
return lhs.dayOfWeek < rhs.dayOfWeek
|
||||||
@@ -416,7 +420,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
|
|
||||||
switch settings.syncModeEnum {
|
switch settings.syncModeEnum {
|
||||||
case .weekComplete:
|
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<Dish>()
|
let descriptor = FetchDescriptor<Dish>()
|
||||||
let dishes = (try? plan.modelContext?.fetch(descriptor)) ?? []
|
let dishes = (try? plan.modelContext?.fetch(descriptor)) ?? []
|
||||||
syncAllAssignedSlotsToCalendar(plan: plan, dishes: dishes, settings: settings)
|
syncAllAssignedSlotsToCalendar(plan: plan, dishes: dishes, settings: settings)
|
||||||
@@ -497,6 +501,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
slot.secondaryDishId = nil
|
slot.secondaryDishId = nil
|
||||||
slot.isRuleOverridden = false
|
slot.isRuleOverridden = false
|
||||||
slot.isEatingOut = true
|
slot.isEatingOut = true
|
||||||
|
slot.isSkipped = false
|
||||||
plan.updatedAt = Date()
|
plan.updatedAt = Date()
|
||||||
applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false)
|
applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false)
|
||||||
HapticManager.shared.impact(style: .medium)
|
HapticManager.shared.impact(style: .medium)
|
||||||
@@ -510,6 +515,33 @@ final class HomeViewModel: ObservableObject {
|
|||||||
HapticManager.shared.impact(style: .light)
|
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) {
|
func acknowledgeViolation(slot: MealSlot, plan: WeekPlan) {
|
||||||
slot.isRuleOverridden = false
|
slot.isRuleOverridden = false
|
||||||
plan.updatedAt = Date()
|
plan.updatedAt = Date()
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ final class OnboardingViewModel: ObservableObject {
|
|||||||
let plans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
|
let plans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
|
||||||
guard let plan = plans.first(where: { $0.weekStartDate == weekStart }) else { return 0 }
|
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 }
|
guard !emptySlots.isEmpty else { return 0 }
|
||||||
|
|
||||||
let result = AutocompleteEngine.autocomplete(
|
let result = AutocompleteEngine.autocomplete(
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ struct HomeView: View {
|
|||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
if let settings = settings,
|
if let settings = settings,
|
||||||
let plan = fetchWeekPlan(for: viewModel.currentWeekStart) {
|
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 {
|
Button {
|
||||||
requestWeekShare(plan: plan, settings: settings)
|
requestWeekShare(plan: plan, settings: settings)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -337,8 +337,8 @@ struct HomeView: View {
|
|||||||
calendarResizeHandle(availableHeight: contentGeo.size.height)
|
calendarResizeHandle(availableHeight: contentGeo.size.height)
|
||||||
}
|
}
|
||||||
|
|
||||||
let filledCount = 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 }.count
|
let emptyCount = plan.slotList.filter { $0.dishId == nil && !$0.isEatingOut && !$0.isSkipped }.count
|
||||||
// Only surface the export banner once the week is actually
|
// Only surface the export banner once the week is actually
|
||||||
// complete (no empty slots) — and let the user dismiss it.
|
// complete (no empty slots) — and let the user dismiss it.
|
||||||
if filledCount > 0 && emptyCount == 0 && !isExportCalloutDismissed(plan) {
|
if filledCount > 0 && emptyCount == 0 && !isExportCalloutDismissed(plan) {
|
||||||
@@ -537,6 +537,14 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
selectedEmptySlotId = nil
|
selectedEmptySlotId = nil
|
||||||
viewModel.markEatingOut(slot: freshSlot, plan: plan, settings: settings)
|
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 onPickDish: (Dish) -> Void
|
||||||
let onCreateDish: () -> Void
|
let onCreateDish: () -> Void
|
||||||
var onEatingOut: (() -> Void)? = nil
|
var onEatingOut: (() -> Void)? = nil
|
||||||
|
var onSkip: (() -> Void)? = nil
|
||||||
/// Set for filled slots: taps add a companion dish instead of replacing
|
/// Set for filled slots: taps add a companion dish instead of replacing
|
||||||
/// when the "second dish" mode is on.
|
/// when the "second dish" mode is on.
|
||||||
var onPickSecondDish: ((Dish) -> Void)? = nil
|
var onPickSecondDish: ((Dish) -> Void)? = nil
|
||||||
@@ -721,6 +730,18 @@ struct HomeView: View {
|
|||||||
.listRowBackground(Color(hex: "#EEF8F5"))
|
.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 {
|
if onPickSecondDish != nil, searchText.isEmpty {
|
||||||
Toggle(isOn: $asSecondDish) {
|
Toggle(isOn: $asSecondDish) {
|
||||||
Label("home_pick_second_dish", systemImage: "plus.square.on.square")
|
Label("home_pick_second_dish", systemImage: "plus.square.on.square")
|
||||||
@@ -898,7 +919,7 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func isWeekComplete(plan: WeekPlan) -> Bool {
|
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] {
|
private var dishUsageCounts: [UUID: Int] {
|
||||||
@@ -1214,7 +1235,7 @@ struct HomeView: View {
|
|||||||
// instead of being asked. Reuses the home's autoComplete choreography.
|
// instead of being asked. Reuses the home's autoComplete choreography.
|
||||||
if UserDefaults.standard.bool(forKey: OnboardingViewModel.pendingAutoFillOnLaunchKey) {
|
if UserDefaults.standard.bool(forKey: OnboardingViewModel.pendingAutoFillOnLaunchKey) {
|
||||||
UserDefaults.standard.set(false, 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)
|
viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings, allPlans: weekPlans)
|
||||||
}
|
}
|
||||||
schedulePostOnboardingPremiumPromptIfNeeded(settings: settings)
|
schedulePostOnboardingPremiumPromptIfNeeded(settings: settings)
|
||||||
|
|||||||
@@ -216,7 +216,8 @@ struct WeekCalendarView: View {
|
|||||||
dishId: preferred.dishId,
|
dishId: preferred.dishId,
|
||||||
secondaryDishId: preferred.secondaryDishId,
|
secondaryDishId: preferred.secondaryDishId,
|
||||||
isRuleOverridden: preferred.isRuleOverridden,
|
isRuleOverridden: preferred.isRuleOverridden,
|
||||||
isEatingOut: preferred.isEatingOut
|
isEatingOut: preferred.isEatingOut,
|
||||||
|
isSkipped: preferred.isSkipped
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +286,14 @@ struct WeekCalendarView: View {
|
|||||||
onTapFilledSlot?(live)
|
onTapFilledSlot?(live)
|
||||||
}
|
}
|
||||||
.draggable("slot:\(slot.slotId.uuidString)")
|
.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 {
|
} else if let slot = slot, slot.isEatingOut {
|
||||||
EatingOutSlotView(mealType: mealType)
|
EatingOutSlotView(mealType: mealType)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
@@ -326,6 +335,7 @@ struct WeekCalendarView: View {
|
|||||||
let secondaryDishId: UUID?
|
let secondaryDishId: UUID?
|
||||||
let isRuleOverridden: Bool
|
let isRuleOverridden: Bool
|
||||||
let isEatingOut: Bool
|
let isEatingOut: Bool
|
||||||
|
let isSkipped: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
private var dishesSnapshotKey: String {
|
private var dishesSnapshotKey: String {
|
||||||
@@ -375,7 +385,8 @@ struct WeekCalendarView: View {
|
|||||||
dishId: preferred.dishId,
|
dishId: preferred.dishId,
|
||||||
secondaryDishId: preferred.secondaryDishId,
|
secondaryDishId: preferred.secondaryDishId,
|
||||||
isRuleOverridden: preferred.isRuleOverridden,
|
isRuleOverridden: preferred.isRuleOverridden,
|
||||||
isEatingOut: preferred.isEatingOut
|
isEatingOut: preferred.isEatingOut,
|
||||||
|
isSkipped: preferred.isSkipped
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -793,6 +793,7 @@ struct WeekPlanShareView: View {
|
|||||||
let slot = plan.slotList.first { $0.dayOfWeek == day && $0.mealType == mealType.rawValue }
|
let slot = plan.slotList.first { $0.dayOfWeek == day && $0.mealType == mealType.rawValue }
|
||||||
guard let slot else { return String(localized: "share_slot_empty") }
|
guard let slot else { return String(localized: "share_slot_empty") }
|
||||||
if slot.isEatingOut { return String(localized: "slot_eating_out") }
|
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 {
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,11 +49,15 @@ struct ShoppingListView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var freeTextItems: [ShoppingItem] {
|
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] {
|
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 {
|
var body: some View {
|
||||||
@@ -66,10 +70,24 @@ struct ShoppingListView: View {
|
|||||||
ForEach(plannedDishes, id: \.id) { dish in
|
ForEach(plannedDishes, id: \.id) { dish in
|
||||||
let dishItems = items(for: dish)
|
let dishItems = items(for: dish)
|
||||||
if !dishItems.isEmpty {
|
if !dishItems.isEmpty {
|
||||||
Section(dish.name) {
|
Section {
|
||||||
ForEach(dishItems, id: \.id) { item in
|
ForEach(dishItems, id: \.id) { item in
|
||||||
itemRow(item)
|
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") {
|
Section("shopping_other_items") {
|
||||||
ForEach(freeTextItems, id: \.id) { item in
|
ForEach(freeTextItems, id: \.id) { item in
|
||||||
itemRow(item)
|
itemRow(item)
|
||||||
@@ -187,6 +227,23 @@ struct ShoppingListView: View {
|
|||||||
Label("shopping_delete", systemImage: "trash")
|
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
|
@ViewBuilder
|
||||||
@@ -274,7 +331,7 @@ struct ShoppingListView: View {
|
|||||||
|
|
||||||
private var exportText: String {
|
private var exportText: String {
|
||||||
ShoppingListService.exportText(
|
ShoppingListService.exportText(
|
||||||
items: items,
|
items: items.filter { !$0.isDismissed },
|
||||||
header: String(localized: "shopping_share_header")
|
header: String(localized: "shopping_share_header")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ struct StatsView: View {
|
|||||||
|
|
||||||
private var completeWeeks: Int {
|
private var completeWeeks: Int {
|
||||||
weekPlans.filter { plan in
|
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
|
}.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user