Version casi lista
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
final class DishViewModel: ObservableObject {
|
||||
@Published var name: String = ""
|
||||
@Published var descriptionText: String = ""
|
||||
@Published var selectedTagIds: Set<UUID> = []
|
||||
@Published var showTagSelector: Bool = false
|
||||
@Published var showDeleteAlert: Bool = false
|
||||
@Published var showAssignedDeleteAlert: Bool = false
|
||||
|
||||
var editingDish: Dish?
|
||||
|
||||
var isEditing: Bool { editingDish != nil }
|
||||
|
||||
var isValid: Bool {
|
||||
!name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
|
||||
func loadDish(_ dish: Dish) {
|
||||
editingDish = dish
|
||||
name = dish.name
|
||||
descriptionText = dish.descriptionText ?? ""
|
||||
selectedTagIds = Set(dish.tagIds)
|
||||
}
|
||||
|
||||
func reset() {
|
||||
editingDish = nil
|
||||
name = ""
|
||||
descriptionText = ""
|
||||
selectedTagIds = []
|
||||
}
|
||||
|
||||
func save(context: ModelContext) {
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmedName.isEmpty else { return }
|
||||
|
||||
if let dish = editingDish {
|
||||
dish.name = trimmedName
|
||||
dish.descriptionText = descriptionText.isEmpty ? nil : descriptionText
|
||||
dish.tagIds = Array(selectedTagIds)
|
||||
} else {
|
||||
let dish = Dish(
|
||||
name: trimmedName,
|
||||
descriptionText: descriptionText.isEmpty ? nil : descriptionText,
|
||||
tagIds: Array(selectedTagIds)
|
||||
)
|
||||
context.insert(dish)
|
||||
}
|
||||
|
||||
try? context.save()
|
||||
reset()
|
||||
}
|
||||
|
||||
func deleteDish(context: ModelContext) -> Bool {
|
||||
guard let dish = editingDish else { return false }
|
||||
|
||||
if isDishAssignedInCurrentWeek(dishId: dish.id, context: context) {
|
||||
showAssignedDeleteAlert = true
|
||||
return false
|
||||
}
|
||||
|
||||
context.delete(dish)
|
||||
try? context.save()
|
||||
reset()
|
||||
return true
|
||||
}
|
||||
|
||||
func canDelete(currentPlan: WeekPlan?) -> Bool {
|
||||
guard let dish = editingDish, let plan = currentPlan else { return true }
|
||||
return !plan.slots.contains { $0.dishId == dish.id }
|
||||
}
|
||||
|
||||
private func isDishAssignedInCurrentWeek(dishId: UUID, context: ModelContext) -> Bool {
|
||||
let currentWeekStart = Date().startOfWeek()
|
||||
let descriptor = FetchDescriptor<WeekPlan>(
|
||||
predicate: #Predicate<WeekPlan> { plan in
|
||||
plan.weekStartDate == currentWeekStart
|
||||
}
|
||||
)
|
||||
|
||||
guard let currentPlan = try? context.fetch(descriptor).first else {
|
||||
return false
|
||||
}
|
||||
|
||||
return currentPlan.slots.contains { $0.dishId == dishId }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
final class OnboardingViewModel: ObservableObject {
|
||||
@Published var currentStep: Int = 0
|
||||
@Published var selectedMealWindows: MealWindows = .dinnerOnly
|
||||
@Published var includeWeekends: Bool = true
|
||||
@Published var syncCalendar: Bool = false
|
||||
@Published var selectedCalendarId: String?
|
||||
@Published var lunchTime: Date = {
|
||||
var c = DateComponents(); c.hour = 14; c.minute = 0
|
||||
return Calendar.current.date(from: c) ?? Date()
|
||||
}()
|
||||
@Published var dinnerTime: Date = {
|
||||
var c = DateComponents(); c.hour = 21; c.minute = 0
|
||||
return Calendar.current.date(from: c) ?? Date()
|
||||
}()
|
||||
|
||||
// First dishes
|
||||
@Published var newDishName: String = ""
|
||||
@Published var newDishTags: Set<UUID> = []
|
||||
@Published var addedDishes: [(name: String, tagIds: [UUID])] = []
|
||||
|
||||
let totalSteps = 5
|
||||
|
||||
var canContinue: Bool {
|
||||
switch currentStep {
|
||||
case 0: return true // Welcome
|
||||
case 1: return true // Meal windows (always has selection)
|
||||
case 2: return true // Weekends (toggle)
|
||||
case 3: return true // Calendar (optional)
|
||||
case 4: return addedDishes.count >= 2 // Need at least 2 dishes
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
var canAddDish: Bool {
|
||||
!newDishName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
func addDish(defaultTagId: UUID? = nil) {
|
||||
guard canAddDish else { return }
|
||||
let normalizedName = newDishName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !addedDishes.contains(where: { $0.name.lowercased() == normalizedName.lowercased() }) else { return }
|
||||
|
||||
let tagIds: [UUID]
|
||||
if !newDishTags.isEmpty {
|
||||
tagIds = Array(newDishTags)
|
||||
} else if let defaultTagId {
|
||||
tagIds = [defaultTagId]
|
||||
} else {
|
||||
tagIds = []
|
||||
}
|
||||
|
||||
addedDishes.append((name: normalizedName, tagIds: tagIds))
|
||||
newDishName = ""
|
||||
newDishTags = []
|
||||
}
|
||||
|
||||
func removeDish(at index: Int) {
|
||||
guard addedDishes.indices.contains(index) else { return }
|
||||
addedDishes.remove(at: index)
|
||||
}
|
||||
|
||||
func addSuggestedDish(name: String, preferredTagNames: [String], availableTags: [Tag]) {
|
||||
let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedName.isEmpty else { return }
|
||||
guard !addedDishes.contains(where: { $0.name.lowercased() == normalizedName.lowercased() }) else { return }
|
||||
|
||||
let matchingTags = availableTags.filter { tag in
|
||||
preferredTagNames.contains(where: { preferred in
|
||||
tag.name.caseInsensitiveCompare(preferred) == .orderedSame ||
|
||||
tag.nameEN.caseInsensitiveCompare(preferred) == .orderedSame
|
||||
})
|
||||
}
|
||||
|
||||
let tagIds: [UUID]
|
||||
if !matchingTags.isEmpty {
|
||||
tagIds = matchingTags.map(\.id)
|
||||
} else if let fallback = availableTags.sorted(by: { $0.sortOrder < $1.sortOrder }).first {
|
||||
tagIds = [fallback.id]
|
||||
} else {
|
||||
tagIds = []
|
||||
}
|
||||
|
||||
addedDishes.append((name: normalizedName, tagIds: tagIds))
|
||||
}
|
||||
|
||||
func nextStep() {
|
||||
if currentStep < totalSteps - 1 {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
currentStep += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func previousStep() {
|
||||
if currentStep > 0 {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
currentStep -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func completeOnboarding(context: ModelContext) -> Bool {
|
||||
// Create or get settings
|
||||
let descriptor = FetchDescriptor<AppSettings>()
|
||||
let settings = (try? context.fetch(descriptor))?.first ?? {
|
||||
let s = AppSettings()
|
||||
context.insert(s)
|
||||
return s
|
||||
}()
|
||||
|
||||
settings.mealWindowsEnum = selectedMealWindows
|
||||
settings.includeWeekends = includeWeekends
|
||||
settings.syncEnabled = syncCalendar
|
||||
settings.calendarId = selectedCalendarId
|
||||
settings.lunchTime = lunchTime
|
||||
settings.dinnerTime = dinnerTime
|
||||
settings.onboardingCompleted = true
|
||||
|
||||
// Create default tags if not exist
|
||||
let tagDescriptor = FetchDescriptor<Tag>()
|
||||
if (try? context.fetch(tagDescriptor))?.isEmpty ?? true {
|
||||
DefaultDataService.createDefaultTags(context: context)
|
||||
}
|
||||
|
||||
// Create dishes
|
||||
for dishData in addedDishes {
|
||||
let dish = Dish(name: dishData.name, tagIds: dishData.tagIds)
|
||||
context.insert(dish)
|
||||
}
|
||||
|
||||
// Create first week plan
|
||||
let weekStart = Date().startOfWeek()
|
||||
let _ = DefaultDataService.createWeekPlan(for: weekStart, settings: settings, context: context)
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
return true
|
||||
} catch {
|
||||
print("Onboarding save failed: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import EventKit
|
||||
|
||||
@MainActor
|
||||
final class SettingsViewModel: ObservableObject {
|
||||
@Published var availableCalendars: [EKCalendar] = []
|
||||
@Published var showCalendarPermissionAlert: Bool = false
|
||||
|
||||
func loadCalendars() {
|
||||
availableCalendars = CalendarService.shared.availableCalendars()
|
||||
}
|
||||
|
||||
func requestCalendarAccess() async -> Bool {
|
||||
let granted = await CalendarService.shared.requestAccess()
|
||||
if !granted {
|
||||
showCalendarPermissionAlert = true
|
||||
} else {
|
||||
loadCalendars()
|
||||
}
|
||||
return granted
|
||||
}
|
||||
|
||||
func openSystemSettings() {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
func updateCalendarEvents(settings: AppSettings, plan: WeekPlan?) {
|
||||
guard let plan = plan, settings.syncEnabled else { return }
|
||||
CalendarService.shared.updateEventsTime(
|
||||
slots: plan.slots,
|
||||
weekStartDate: plan.weekStartDate,
|
||||
settings: settings
|
||||
)
|
||||
}
|
||||
|
||||
func resetAllData(context: ModelContext) {
|
||||
do {
|
||||
try deleteAll(of: MealSlot.self, in: context)
|
||||
try deleteAll(of: WeekPlan.self, in: context)
|
||||
try deleteAll(of: Dish.self, in: context)
|
||||
try deleteAll(of: Tag.self, in: context)
|
||||
try deleteAll(of: AppSettings.self, in: context)
|
||||
|
||||
DefaultDataService.createDefaultSettings(context: context)
|
||||
DefaultDataService.createDefaultTags(context: context)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("Failed to reset all data: \(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteAll<T: PersistentModel>(of type: T.Type, in context: ModelContext) throws {
|
||||
let descriptor = FetchDescriptor<T>()
|
||||
let models = try context.fetch(descriptor)
|
||||
for model in models {
|
||||
context.delete(model)
|
||||
}
|
||||
try context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
final class TagViewModel: ObservableObject {
|
||||
@Published var editingTag: Tag?
|
||||
@Published var maxPerWeek: Int? = nil
|
||||
@Published var noConsecutive: Bool = false
|
||||
@Published var noDuplicateInDay: Bool = false
|
||||
@Published var mealTypeRestriction: String? = nil
|
||||
@Published var useMaxLimit: Bool = true
|
||||
|
||||
func loadTag(_ tag: Tag) {
|
||||
editingTag = tag
|
||||
maxPerWeek = tag.maxPerWeek
|
||||
noConsecutive = tag.noConsecutive
|
||||
noDuplicateInDay = tag.noDuplicateInDay
|
||||
mealTypeRestriction = tag.mealTypeRestriction
|
||||
useMaxLimit = tag.maxPerWeek != nil
|
||||
}
|
||||
|
||||
func save() {
|
||||
guard let tag = editingTag else { return }
|
||||
tag.maxPerWeek = useMaxLimit ? (maxPerWeek ?? 3) : nil
|
||||
tag.noConsecutive = noConsecutive
|
||||
tag.noDuplicateInDay = noDuplicateInDay
|
||||
tag.mealTypeRestriction = mealTypeRestriction
|
||||
}
|
||||
|
||||
func reset() {
|
||||
editingTag = nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user