import SwiftUI import SwiftData @MainActor final class HomeViewModel: ObservableObject { struct InvalidDropContext: Identifiable { let id = UUID() let dish: Dish let slot: MealSlot } private struct SlotSnapshot { let dishId: UUID? let isRuleOverridden: Bool } @Published var currentWeekStart: Date @Published var isAutoCompleting: Bool = false @Published var showConfetti: Bool = false @Published var showToast: Bool = false @Published var toastMessage: String = "" @Published var showResetAlert: Bool = false @Published var showDishForm: Bool = false @Published var draggedDish: Dish? @Published var invalidDropContext: InvalidDropContext? @Published private(set) var hasUndoSnapshot: Bool = false private var lastWeekStartSnapshot: Date? private var lastSlotsSnapshot: [UUID: SlotSnapshot] = [:] private var isApplyingUndo: Bool = false init() { self.currentWeekStart = Date().startOfWeek() } func goToPreviousWeek() { withAnimation(.easeInOut(duration: 0.3)) { currentWeekStart = currentWeekStart.addingDays(-7) } } func goToNextWeek() { withAnimation(.easeInOut(duration: 0.3)) { currentWeekStart = currentWeekStart.addingDays(7) } } func jumpToWeek(startDate: Date) { withAnimation(.easeInOut(duration: 0.25)) { currentWeekStart = startDate.startOfWeek() } } var weekRangeText: String { currentWeekStart.formattedWeekRange() } var canEditCurrentWeek: Bool { currentWeekStart >= Date().startOfWeek() } func getOrCreateWeekPlan(context: ModelContext, settings: AppSettings) -> WeekPlan? { let start = currentWeekStart let descriptor = FetchDescriptor( predicate: #Predicate { plan in plan.weekStartDate == start } ) if let existing = try? context.fetch(descriptor).first { return existing } return DefaultDataService.createWeekPlan(for: currentWeekStart, settings: settings, context: context) } func reconcileSlotsIfNeeded(plan: WeekPlan, settings: AppSettings, context: ModelContext) { syncSlotsIfNeeded(plan: plan, settings: settings, context: context) } func assignDish(_ dish: Dish, to slot: MealSlot, plan: WeekPlan, settings: AppSettings, isOverride: Bool = false) { captureUndoSnapshot(plan: plan) if let oldEventId = slot.calendarEventId { CalendarService.shared.deleteEvent(eventId: oldEventId) slot.calendarEventId = nil } slot.dishId = dish.id slot.isRuleOverridden = isOverride plan.updatedAt = Date() applyCalendarSyncPolicy(plan: plan, settings: settings) HapticManager.shared.notification(type: .success) showToastMessage(localizedString("toast_dish_assigned", language: settings.languageEnum.resolved())) } func removeDish(from 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.isRuleOverridden = false plan.updatedAt = Date() applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false) HapticManager.shared.notification(type: .warning) } func autoComplete(plan: WeekPlan, dishes: [Dish], tags: [Tag], settings: AppSettings) { captureUndoSnapshot(plan: plan) isAutoCompleting = true let emptySlots = plan.slots.filter { $0.dishId == nil } let result = AutocompleteEngine.autocomplete( emptySlots: emptySlots, currentPlan: plan, allDishes: dishes, allTags: tags ) plan.updatedAt = Date() applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false) Task { for _ in result.filledSlots { try? await Task.sleep(nanoseconds: 150_000_000) HapticManager.shared.impact(style: .light) } isAutoCompleting = false if result.unfilledCount > 0 { showToastMessage(localizedString("toast_cannot_complete", language: settings.languageEnum.resolved())) } else { showConfetti = true HapticManager.shared.notification(type: .success) showToastMessage(localizedString("toast_week_complete", language: settings.languageEnum.resolved())) DispatchQueue.main.asyncAfter(deadline: .now() + 2) { self.showConfetti = false } } } } func resetWeek(plan: WeekPlan, settings: AppSettings) { captureUndoSnapshot(plan: plan) for slot in plan.slots { if let eventId = slot.calendarEventId { CalendarService.shared.deleteEvent(eventId: eventId) slot.calendarEventId = nil } slot.dishId = nil slot.isRuleOverridden = false } plan.updatedAt = Date() HapticManager.shared.notification(type: .warning) showToastMessage(localizedString("toast_week_reset", language: settings.languageEnum.resolved())) } func assignDishToFirstFreeSlot( _ dish: Dish, plan: WeekPlan, settings: AppSettings, allTags: [Tag], allDishes: [Dish] ) { guard let slot = firstFreeSlot(in: plan) else { showToastMessage(localizedString("toast_no_free_slots", language: settings.languageEnum.resolved())) HapticManager.shared.notification(type: .warning) return } let isValid = AutocompleteEngine.validateDrop( dish: dish, slot: slot, plan: plan, allTags: allTags, allDishes: allDishes ) if isValid { assignDish(dish, to: slot, plan: plan, settings: settings) } else { confirmInvalidDrop(dish, to: slot, plan: plan, settings: settings) } } func syncWeekToCalendar(plan: WeekPlan, dishes: [Dish], settings: AppSettings) { guard settings.syncEnabled else { return } syncAllAssignedSlotsToCalendar(plan: plan, dishes: dishes, settings: settings) showToastMessage(localizedString("toast_calendar_synced", language: settings.languageEnum.resolved())) } func moveOrSwapDish( from sourceSlot: MealSlot, to targetSlot: MealSlot, plan: WeekPlan, settings: AppSettings, allTags: [Tag], allDishes: [Dish] ) { guard sourceSlot.id != targetSlot.id else { return } guard let sourceDishId = sourceSlot.dishId else { return } captureUndoSnapshot(plan: plan) if let sourceEventId = sourceSlot.calendarEventId { CalendarService.shared.deleteEvent(eventId: sourceEventId) sourceSlot.calendarEventId = nil } if let targetEventId = targetSlot.calendarEventId { CalendarService.shared.deleteEvent(eventId: targetEventId) targetSlot.calendarEventId = nil } let targetDishId = targetSlot.dishId sourceSlot.dishId = targetDishId targetSlot.dishId = sourceDishId sourceSlot.isRuleOverridden = false targetSlot.isRuleOverridden = false updateRuleOverrideFlag(for: sourceSlot, plan: plan, allTags: allTags, allDishes: allDishes) updateRuleOverrideFlag(for: targetSlot, plan: plan, allTags: allTags, allDishes: allDishes) plan.updatedAt = Date() applyCalendarSyncPolicy(plan: plan, settings: settings) HapticManager.shared.notification(type: .success) showToastMessage(localizedString("toast_dish_assigned", language: settings.languageEnum.resolved())) } func copyFromPreviousWeek( currentPlan: WeekPlan, previousPlan: WeekPlan?, settings: AppSettings, allTags: [Tag], allDishes: [Dish] ) { guard let previousPlan else { showToastMessage(localizedString("toast_previous_week_empty", language: settings.languageEnum.resolved())) HapticManager.shared.notification(type: .warning) return } captureUndoSnapshot(plan: currentPlan) var previousByKey: [SlotKey: MealSlot] = [:] for slot in previousPlan.slots { previousByKey[SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType)] = slot } let dishIds = Set(allDishes.map(\.id)) for slot in currentPlan.slots { if let eventId = slot.calendarEventId { CalendarService.shared.deleteEvent(eventId: eventId) slot.calendarEventId = nil } let key = SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType) if let previousDishId = previousByKey[key]?.dishId, dishIds.contains(previousDishId) { slot.dishId = previousDishId } else { slot.dishId = nil } slot.isRuleOverridden = false updateRuleOverrideFlag(for: slot, plan: currentPlan, allTags: allTags, allDishes: allDishes) } currentPlan.updatedAt = Date() applyCalendarSyncPolicy(plan: currentPlan, settings: settings) HapticManager.shared.notification(type: .success) showToastMessage(localizedString("toast_copied_previous_week", language: settings.languageEnum.resolved())) } func canUndo(for plan: WeekPlan) -> Bool { hasUndoSnapshot && lastWeekStartSnapshot == plan.weekStartDate } func undoLastAction(plan: WeekPlan, settings: AppSettings) { guard canUndo(for: plan) else { return } isApplyingUndo = true for slot in plan.slots { if let eventId = slot.calendarEventId { CalendarService.shared.deleteEvent(eventId: eventId) } slot.calendarEventId = nil if let snap = lastSlotsSnapshot[slot.id] { slot.dishId = snap.dishId slot.isRuleOverridden = snap.isRuleOverridden } else { slot.dishId = nil slot.isRuleOverridden = false } } plan.updatedAt = Date() applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false) clearUndoSnapshot() isApplyingUndo = false HapticManager.shared.notification(type: .success) showToastMessage(localizedString("toast_undo_applied", language: settings.languageEnum.resolved())) } private func showToastMessage(_ message: String) { toastMessage = message withAnimation(.spring()) { showToast = true } } private func captureUndoSnapshot(plan: WeekPlan) { guard !isApplyingUndo else { return } lastWeekStartSnapshot = plan.weekStartDate lastSlotsSnapshot = Dictionary(uniqueKeysWithValues: plan.slots.map { slot in (slot.id, SlotSnapshot(dishId: slot.dishId, isRuleOverridden: slot.isRuleOverridden)) }) hasUndoSnapshot = true } private func clearUndoSnapshot() { lastWeekStartSnapshot = nil lastSlotsSnapshot.removeAll() hasUndoSnapshot = false } private func firstFreeSlot(in plan: WeekPlan) -> MealSlot? { plan.slots .filter { $0.dishId == nil } .sorted { lhs, rhs in if lhs.dayOfWeek != rhs.dayOfWeek { return lhs.dayOfWeek < rhs.dayOfWeek } return mealTypeOrder(lhs.mealType) < mealTypeOrder(rhs.mealType) } .first } private func mealTypeOrder(_ raw: String) -> Int { raw == MealType.lunch.rawValue ? 0 : 1 } private func applyCalendarSyncPolicy(plan: WeekPlan, settings: AppSettings, shouldNotify: Bool = true) { guard settings.syncEnabled else { return } switch settings.syncModeEnum { case .weekComplete: if plan.slots.allSatisfy({ $0.dishId != nil }) { let descriptor = FetchDescriptor() let dishes = (try? plan.modelContext?.fetch(descriptor)) ?? [] syncAllAssignedSlotsToCalendar(plan: plan, dishes: dishes, settings: settings) if shouldNotify { showToastMessage(localizedString("toast_calendar_synced", language: settings.languageEnum.resolved())) } } else { clearCalendarEvents(for: plan) } case .manual: break } } private func clearCalendarEvents(for plan: WeekPlan) { for slot in plan.slots { if let eventId = slot.calendarEventId { CalendarService.shared.deleteEvent(eventId: eventId) slot.calendarEventId = nil } } } private func syncAllAssignedSlotsToCalendar(plan: WeekPlan, dishes: [Dish], settings: AppSettings) { let dishById = Dictionary(uniqueKeysWithValues: dishes.map { ($0.id, $0) }) for slot in plan.slots { if let oldEventId = slot.calendarEventId { CalendarService.shared.deleteEvent(eventId: oldEventId) slot.calendarEventId = nil } guard let dishId = slot.dishId, let dish = dishById[dishId] else { continue } slot.calendarEventId = CalendarService.shared.createEvent( slot: slot, dishName: dish.name, dishDescription: dish.descriptionText, weekStartDate: currentWeekStart, settings: settings ) } } private func updateRuleOverrideFlag(for slot: MealSlot, plan: WeekPlan, allTags: [Tag], allDishes: [Dish]) { guard let dishId = slot.dishId, let dish = allDishes.first(where: { $0.id == dishId }) else { slot.isRuleOverridden = false return } let valid = AutocompleteEngine.validateDrop( dish: dish, slot: slot, plan: plan, allTags: allTags, allDishes: allDishes ) slot.isRuleOverridden = !valid } func confirmInvalidDrop(_ dish: Dish, to slot: MealSlot, plan: WeekPlan, settings: AppSettings) { invalidDropContext = InvalidDropContext(dish: dish, slot: slot) HapticManager.shared.notification(type: .warning) } private struct SlotKey: Hashable { let dayOfWeek: Int let mealType: String } private func syncSlotsIfNeeded(plan: WeekPlan, settings: AppSettings, context: ModelContext) { let maxDay = settings.includeWeekends ? 6 : 4 let mealTypes: [String] = { switch settings.mealWindowsEnum { case .dinnerOnly: return ["dinner"] case .lunchOnly: return ["lunch"] case .both: return ["lunch", "dinner"] } }() // Clean up any legacy duplicates for the same day+mealType key to keep UI mapping stable. var groupedByKey: [SlotKey: [MealSlot]] = [:] for slot in plan.slots { let key = SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType) groupedByKey[key, default: []].append(slot) } var changed = false for (_, duplicates) in groupedByKey where duplicates.count > 1 { let keeper = MealSlot.preferredForDuplicateResolution(duplicates) for duplicate in duplicates where duplicate.id != keeper.id { if keeper.dishId == nil, let dishId = duplicate.dishId { keeper.dishId = dishId keeper.isRuleOverridden = duplicate.isRuleOverridden if keeper.calendarEventId == nil { keeper.calendarEventId = duplicate.calendarEventId } } else if let duplicateEventId = duplicate.calendarEventId { CalendarService.shared.deleteEvent(eventId: duplicateEventId) } plan.slots.removeAll { $0.id == duplicate.id } context.delete(duplicate) changed = true } } var desired = Set() for day in 0...maxDay { for mealType in mealTypes { desired.insert(SlotKey(dayOfWeek: day, mealType: mealType)) } } var existingByKey: [SlotKey: MealSlot] = [:] for slot in plan.slots { let key = SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType) if existingByKey[key] == nil { existingByKey[key] = slot } } let toDelete = plan.slots.filter { slot in !desired.contains(SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType)) } for slot in toDelete { if let eventId = slot.calendarEventId { CalendarService.shared.deleteEvent(eventId: eventId) } plan.slots.removeAll { $0.id == slot.id } context.delete(slot) changed = true } for key in desired where existingByKey[key] == nil { let slot = MealSlot(dayOfWeek: key.dayOfWeek, mealType: key.mealType) slot.weekPlan = plan plan.slots.append(slot) context.insert(slot) changed = true } if changed { plan.updatedAt = Date() try? context.save() } } }