Version casi lista
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
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 {
|
||||
syncSlotsIfNeeded(plan: existing, settings: settings, context: context)
|
||||
return existing
|
||||
}
|
||||
|
||||
return DefaultDataService.createWeekPlan(for: currentWeekStart, 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"]
|
||||
}
|
||||
}()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
var changed = false
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user