500 lines
18 KiB
Swift
500 lines
18 KiB
Swift
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<WeekPlan>(
|
|
predicate: #Predicate<WeekPlan> { 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<Dish>()
|
|
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<SlotKey>()
|
|
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()
|
|
}
|
|
}
|
|
}
|