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
+89
View File
@@ -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 }
}
}
+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()
}
}
}
@@ -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()
}
}
+33
View File
@@ -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
}
}