Version casi lista

This commit is contained in:
alexandrev-tibco
2026-02-17 13:34:02 +01:00
commit e593453abd
99 changed files with 10867 additions and 0 deletions
+470
View File
@@ -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()
}
}
}