From 4ffa15b06afd4855f1a6bffcda910e38948ab68a Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Sun, 12 Jul 2026 18:13:45 +0200 Subject: [PATCH] 2.0: CloudKit private-database sync replaces KV snapshot sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for 2.1 family sharing (CKShare needs these models + container). SwiftData cannot share across Apple IDs today, so 2.0 ships true multi-device sync for the same account instead: - Models made CloudKit-compatible: dropped @Attribute(.unique) on all 5, inline defaults on every attribute, WeekPlan.slots stored as optional relationship (name preserved → lightweight migration) with non-optional slotList facade; ~73 call sites renamed. - Container: cloudKitDatabase .automatic, falling back to the local-only store when CloudKit is unavailable; failures recorded to Crashlytics. - Entitlements: iCloud CloudKit service (container already existed); remote-notification background mode for push-driven sync. - Legacy KV snapshot sync (ICloudSyncService) stays inert when CloudKit is active — kept only for 1.x devices. - DeduplicationService collapses cross-device duplicates deterministically on launch (settings singleton, default tags with tagId remapping, same-week plans). - Note: AppSettings.isPremium now syncs across same-Apple-ID devices; StoreKit (PremiumSyncService + Transaction.updates) remains the source of truth and reconciles on every launch/foreground. All 21 unit tests pass, including ICloudSyncPremiumIsolationTests. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG --- MealMood.xcodeproj/project.pbxproj | 4 + MealMood/ContentView.swift | 5 ++ MealMood/MealMoodApp.swift | 22 +++++ MealMood/Models/AppSettings.swift | 13 +-- MealMood/Models/Dish.swift | 9 +- MealMood/Models/MealSlot.swift | 7 +- MealMood/Models/ShoppingItem.swift | 9 +- MealMood/Models/Tag.swift | 11 +-- MealMood/Models/WeekPlan.swift | 19 +++-- MealMood/Resources/Info.plist | 4 + MealMood/Resources/MealMood.entitlements | 4 + MealMood/Services/AutocompleteEngine.swift | 20 ++--- MealMood/Services/DeduplicationService.swift | 83 +++++++++++++++++++ MealMood/Services/DefaultDataService.swift | 2 +- MealMood/Services/ICloudSyncService.swift | 8 +- MealMood/Services/NotificationService.swift | 4 +- MealMood/Services/WidgetDataStore.swift | 2 +- MealMood/ViewModels/DishViewModel.swift | 4 +- MealMood/ViewModels/HomeViewModel.swift | 32 +++---- MealMood/ViewModels/OnboardingViewModel.swift | 2 +- MealMood/ViewModels/SettingsViewModel.swift | 2 +- MealMood/Views/Dishes/DishListView.swift | 2 +- MealMood/Views/Home/HomeView.swift | 42 +++++----- MealMood/Views/Home/WeekCalendarView.swift | 10 +-- MealMood/Views/Home/WeekPlanShareView.swift | 2 +- .../Views/Shopping/ShoppingListView.swift | 2 +- MealMood/Views/Stats/StatsView.swift | 8 +- MealMoodTests/AutocompleteEngineTests.swift | 4 +- 28 files changed, 238 insertions(+), 98 deletions(-) create mode 100644 MealMood/Services/DeduplicationService.swift diff --git a/MealMood.xcodeproj/project.pbxproj b/MealMood.xcodeproj/project.pbxproj index 8aa333a..5e9d66d 100644 --- a/MealMood.xcodeproj/project.pbxproj +++ b/MealMood.xcodeproj/project.pbxproj @@ -36,6 +36,7 @@ 5E7322F3F3624211985F2D99 /* ICloudSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED090ADA1FB259AD2AFEDE2 /* ICloudSyncService.swift */; }; 5ECA2501EC257777DBE670AD /* EmptySlotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68E5C82608F60FF8798BB81 /* EmptySlotView.swift */; }; 6553473B007AFD04079878C6 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F0A5C2FF1B797A4BB69B39 /* ContentView.swift */; }; + 6685C591F837FAFB29A322F7 /* DeduplicationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C3CBAFE191AD6643F861B7 /* DeduplicationService.swift */; }; 670A11A9850B321D9FF76B41 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D718B2E4F7E6D22E502F0A6 /* OnboardingView.swift */; }; 6C808D1B6E8534FABC4A4053 /* WeekPlanShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E113A29DEC0551C41AB7EBB /* WeekPlanShareView.swift */; }; 717C67C9A7F48587667B5AE2 /* Dish.swift in Sources */ = {isa = PBXBuildFile; fileRef = 623EDDC4CD134883FC9D242D /* Dish.swift */; }; @@ -174,6 +175,7 @@ 965555A7774FF7EB975F0A07 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 9F86EB5DDB126D73486FD956 /* MealMood.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MealMood.entitlements; sourceTree = ""; }; 9FBA11C8A81D5C0CA8A3C13D /* MealWindowsStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealWindowsStepView.swift; sourceTree = ""; }; + A1C3CBAFE191AD6643F861B7 /* DeduplicationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeduplicationService.swift; sourceTree = ""; }; A23267BAA116E1214870416C /* WeekPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekPlan.swift; sourceTree = ""; }; A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackStore.swift; sourceTree = ""; }; A6FC2153490D53B80DBDC034 /* WidgetDataStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = ""; }; @@ -519,6 +521,7 @@ A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */, 71F0ADA350289AE410D572A7 /* IngredientGenerator.swift */, 5C512046B1410E236A3F6286 /* ShoppingListService.swift */, + A1C3CBAFE191AD6643F861B7 /* DeduplicationService.swift */, ); path = Services; sourceTree = ""; @@ -761,6 +764,7 @@ B8FDF31D3BD7DD7220CE21A3 /* ShoppingItem.swift in Sources */, 44E43D5F00D1B513F9BABE80 /* ShoppingListService.swift in Sources */, 57F1EA6B5E3AF88A61C48354 /* ShoppingListView.swift in Sources */, + 6685C591F837FAFB29A322F7 /* DeduplicationService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/MealMood/ContentView.swift b/MealMood/ContentView.swift index 4780a8c..ee50fd4 100644 --- a/MealMood/ContentView.swift +++ b/MealMood/ContentView.swift @@ -91,6 +91,11 @@ struct ContentView: View { // MARK: - Private private func initializeAppIfNeeded() { + // CloudKit mirroring can duplicate singletons/default data created on + // other devices — collapse them before anything reads the store. + if CloudSyncRuntime.isCloudKitActive { + DeduplicationService.run(context: context) + } if allSettings.isEmpty { DefaultDataService.createDefaultSettings(context: context) } diff --git a/MealMood/MealMoodApp.swift b/MealMood/MealMoodApp.swift index f7df5d4..90056ea 100644 --- a/MealMood/MealMoodApp.swift +++ b/MealMood/MealMoodApp.swift @@ -6,6 +6,14 @@ import FirebaseCrashlytics import GoogleMobileAds #endif +/// Whether the store is syncing through CloudKit this launch. When true, the +/// legacy iCloud KV snapshot sync must stay inert (both would fight). +enum CloudSyncRuntime { + // Written exactly once during app start (before any concurrency), then + // read-only for the rest of the launch. + nonisolated(unsafe) static var isCloudKitActive = false +} + @main struct MealMoodApp: App { private let modelContainer: ModelContainer = { @@ -17,6 +25,20 @@ struct MealMoodApp: App { MealSlot.self, ShoppingItem.self ]) + + // 2.0: CloudKit private-database sync (multi-device, same Apple ID). + // Falls back to the local-only store when CloudKit isn't available + // (signed-out iCloud, missing entitlement in dev builds, etc.). + let cloudConfiguration = ModelConfiguration(cloudKitDatabase: .automatic) + do { + let container = try ModelContainer(for: schema, configurations: [cloudConfiguration]) + CloudSyncRuntime.isCloudKitActive = true + return container + } catch { + CrashlyticsService.record(error, context: "cloudkit_container") + print("CloudKit container unavailable, using local store: \(error)") + } + let configuration = ModelConfiguration(cloudKitDatabase: .none) do { return try ModelContainer(for: schema, configurations: [configuration]) diff --git a/MealMood/Models/AppSettings.swift b/MealMood/Models/AppSettings.swift index 958d7eb..1dc8631 100644 --- a/MealMood/Models/AppSettings.swift +++ b/MealMood/Models/AppSettings.swift @@ -1,11 +1,12 @@ import Foundation import SwiftData +// CloudKit-compatible (2.0): no unique constraints, inline defaults everywhere. @Model final class AppSettings { - var mealWindows: String // "dinnerOnly", "lunchOnly", "both" + var mealWindows: String = "dinnerOnly" // legacy: "dinnerOnly", "lunchOnly", "both" var includeWeekends: Bool = true - var language: String // "spanish", "english" + var language: String = "system" /// Comma-separated MealType raw values, in chronological order (2.0). /// nil = derive from the legacy `mealWindows` field (pre-2.0 installs). @@ -14,12 +15,12 @@ final class AppSettings { var calendarId: String? var syncEnabled: Bool = false var syncMode: String? // "weekComplete", "manual" - var lunchTime: Date - var dinnerTime: Date + var lunchTime: Date = AppSettings.defaultTime(hour: 14) + var dinnerTime: Date = AppSettings.defaultTime(hour: 21) var breakfastTime: Date? // optional: pre-2.0 stores lack it var snackTime: Date? - var eventDuration: Int - var eventPrefix: String + var eventDuration: Int = 60 + var eventPrefix: String = "🍽️" var reminderMinutesBefore: Int? var iCloudSyncEnabled: Bool? var weekExportStyle: String? diff --git a/MealMood/Models/Dish.swift b/MealMood/Models/Dish.swift index a8f36e5..cdbbecd 100644 --- a/MealMood/Models/Dish.swift +++ b/MealMood/Models/Dish.swift @@ -1,13 +1,14 @@ import Foundation import SwiftData +// CloudKit-compatible (2.0): no unique constraints, inline defaults everywhere. @Model final class Dish { - @Attribute(.unique) var id: UUID - var name: String + var id: UUID = UUID() + var name: String = "" var descriptionText: String? - var tagIds: [UUID] - var createdAt: Date + var tagIds: [UUID] = [] + var createdAt: Date = Date() var isPriority: Bool = false /// Optional ingredient lines (one per entry, e.g. "200g spaghetti"). diff --git a/MealMood/Models/MealSlot.swift b/MealMood/Models/MealSlot.swift index 2813c83..7a73b8c 100644 --- a/MealMood/Models/MealSlot.swift +++ b/MealMood/Models/MealSlot.swift @@ -1,11 +1,12 @@ import Foundation import SwiftData +// CloudKit-compatible (2.0): no unique constraints, inline defaults everywhere. @Model final class MealSlot { - @Attribute(.unique) var id: UUID - var dayOfWeek: Int // 0=Monday, 6=Sunday - var mealType: String // "lunch", "dinner" + var id: UUID = UUID() + var dayOfWeek: Int = 0 // 0=Monday, 6=Sunday + var mealType: String = MealType.dinner.rawValue var dishId: UUID? var calendarEventId: String? var isRuleOverridden: Bool = false diff --git a/MealMood/Models/ShoppingItem.swift b/MealMood/Models/ShoppingItem.swift index 6774651..04a8dfe 100644 --- a/MealMood/Models/ShoppingItem.swift +++ b/MealMood/Models/ShoppingItem.swift @@ -4,15 +4,16 @@ import SwiftData /// One line of the weekly shopping list. Items are either derived from a /// planned dish's ingredients (`dishId` set) or added free-hand by the user /// (`dishId == nil`). Check-off state persists across app launches. +// CloudKit-compatible (2.0): no unique constraints, inline defaults everywhere. @Model final class ShoppingItem { - @Attribute(.unique) var id: UUID - var weekStartDate: Date - var title: String + var id: UUID = UUID() + var weekStartDate: Date = Date() + var title: String = "" var dishId: UUID? var isChecked: Bool = false var sortOrder: Int = 0 - var createdAt: Date + var createdAt: Date = Date() init( id: UUID = UUID(), diff --git a/MealMood/Models/Tag.swift b/MealMood/Models/Tag.swift index e93cde4..ea3f861 100644 --- a/MealMood/Models/Tag.swift +++ b/MealMood/Models/Tag.swift @@ -1,19 +1,20 @@ import Foundation import SwiftData +// CloudKit-compatible (2.0): no unique constraints, inline defaults everywhere. @Model final class Tag { - @Attribute(.unique) var id: UUID - var name: String - var nameEN: String - var color: String + var id: UUID = UUID() + var name: String = "" + var nameEN: String = "" + var color: String = "" var maxPerWeek: Int? var noConsecutive: Bool = false var noDuplicateInDay: Bool = false var mealTypeRestriction: String? // "lunch", "dinner", nil var dayRestriction: String? // nil = any day, "weekdays" = Mon-Fri, "weekend" = Sat-Sun var isDefault: Bool = true - var sortOrder: Int + var sortOrder: Int = 0 init( id: UUID = UUID(), diff --git a/MealMood/Models/WeekPlan.swift b/MealMood/Models/WeekPlan.swift index 2d2b794..2f00b1d 100644 --- a/MealMood/Models/WeekPlan.swift +++ b/MealMood/Models/WeekPlan.swift @@ -1,16 +1,25 @@ import Foundation import SwiftData +// CloudKit-compatible (2.0): no unique constraints, inline defaults everywhere, +// and the relationship stored as optional (CloudKit requires it). `slots` keeps +// its name so existing stores lightweight-migrate; use `slotList` in code. @Model final class WeekPlan { - @Attribute(.unique) var id: UUID - var weekStartDate: Date - var createdAt: Date - var updatedAt: Date + var id: UUID = UUID() + var weekStartDate: Date = Date() + var createdAt: Date = Date() + var updatedAt: Date = Date() var userRating: Int = 0 // 0 = unrated, 1 = liked, -1 = disliked @Relationship(deleteRule: .cascade) - var slots: [MealSlot] + var slots: [MealSlot]? = [] + + /// Non-optional facade over the CloudKit-required optional relationship. + var slotList: [MealSlot] { + get { slots ?? [] } + set { slots = newValue } + } init( id: UUID = UUID(), diff --git a/MealMood/Resources/Info.plist b/MealMood/Resources/Info.plist index 6add359..4e89262 100644 --- a/MealMood/Resources/Info.plist +++ b/MealMood/Resources/Info.plist @@ -16,6 +16,10 @@ APPL CFBundleShortVersionString 2.0 + UIBackgroundModes + + remote-notification + CFBundleURLTypes diff --git a/MealMood/Resources/MealMood.entitlements b/MealMood/Resources/MealMood.entitlements index c04f40f..16399fa 100644 --- a/MealMood/Resources/MealMood.entitlements +++ b/MealMood/Resources/MealMood.entitlements @@ -6,6 +6,10 @@ iCloud.com.alexandrev.mealmood + com.apple.developer.icloud-services + + CloudKit + com.apple.developer.ubiquity-kvstore-identifier $(TeamIdentifierPrefix)$(CFBundleIdentifier) com.apple.security.application-groups diff --git a/MealMood/Services/AutocompleteEngine.swift b/MealMood/Services/AutocompleteEngine.swift index 108eb3f..f1fb077 100644 --- a/MealMood/Services/AutocompleteEngine.swift +++ b/MealMood/Services/AutocompleteEngine.swift @@ -34,7 +34,7 @@ struct AutocompleteEngine { var pendingSlots = emptySlots.filter { !$0.isEatingOut } // MRV: at each step pick the slot with fewest valid candidates first. - // Assignments update currentPlan.slots in-place, so subsequent candidate + // Assignments update currentPlan.slotList in-place, so subsequent candidate // counts automatically reflect the growing set of committed dishes. while !pendingSlots.isEmpty { // Score each remaining slot by strict candidate count @@ -78,7 +78,7 @@ struct AutocompleteEngine { static func findViolations(plan: WeekPlan, allDishes: [Dish], allTags: [Tag]) -> [RuleViolation] { let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) }) - return plan.slots.compactMap { slot in + return plan.slotList.compactMap { slot in guard let dishId = slot.dishId, let dish = dishMap[dishId], slot.isRuleOverridden, @@ -119,7 +119,7 @@ struct AutocompleteEngine { } private static func violatesDefaultNoRepeatRule(dish: Dish, slot: MealSlot, plan: WeekPlan) -> Bool { - plan.slots.contains { $0.id != slot.id && $0.dishId == dish.id } + plan.slotList.contains { $0.id != slot.id && $0.dishId == dish.id } } private static func violatesExplicitRules( @@ -134,7 +134,7 @@ struct AutocompleteEngine { if let maxPerWeek = tag.maxPerWeek { var count = 0 - for s in plan.slots { + for s in plan.slotList { guard let did = s.dishId, let d = dishMap[did] else { continue } if d.tagIds.contains(tagId) { count += 1 } } @@ -149,7 +149,7 @@ struct AutocompleteEngine { } if tag.noDuplicateInDay { - for sdSlot in plan.slots where sdSlot.dayOfWeek == slot.dayOfWeek && sdSlot.id != slot.id { + for sdSlot in plan.slotList where sdSlot.dayOfWeek == slot.dayOfWeek && sdSlot.id != slot.id { guard let did = sdSlot.dishId, let d = dishMap[did] else { continue } if d.tagIds.contains(tagId) { return true } } @@ -173,11 +173,11 @@ struct AutocompleteEngine { private static func adjacentSlots(of slot: MealSlot, in plan: WeekPlan) -> [MealSlot] { var result: [MealSlot] = [] if slot.dayOfWeek > 0, - let prev = plan.slots.first(where: { $0.dayOfWeek == slot.dayOfWeek - 1 && $0.mealType == slot.mealType }) { + let prev = plan.slotList.first(where: { $0.dayOfWeek == slot.dayOfWeek - 1 && $0.mealType == slot.mealType }) { result.append(prev) } if slot.dayOfWeek < 6, - let next = plan.slots.first(where: { $0.dayOfWeek == slot.dayOfWeek + 1 && $0.mealType == slot.mealType }) { + let next = plan.slotList.first(where: { $0.dayOfWeek == slot.dayOfWeek + 1 && $0.mealType == slot.mealType }) { result.append(next) } return result @@ -201,7 +201,7 @@ struct AutocompleteEngine { // Week offsets: how many weeks ago did this dish appear? var offsets: [Int] = [] for plan in recentPlans { - guard plan.slots.contains(where: { $0.dishId == dish.id }) else { continue } + guard plan.slotList.contains(where: { $0.dishId == dish.id }) else { continue } let diff = calendar.dateComponents([.weekOfYear], from: plan.weekStartDate, to: currentWeekStart).weekOfYear ?? 0 if diff > 0 { offsets.append(diff) } } @@ -261,7 +261,7 @@ struct AutocompleteEngine { for dish in dishes { var liked = 0, disliked = 0 for plan in ratedPlans { - guard plan.slots.contains(where: { $0.dishId == dish.id }) else { continue } + guard plan.slotList.contains(where: { $0.dishId == dish.id }) else { continue } if plan.userRating > 0 { liked += 1 } else { disliked += 1 } } guard liked + disliked > 0 else { continue } @@ -283,7 +283,7 @@ struct AutocompleteEngine { ) -> Dish? { guard !candidates.isEmpty else { return nil } let weights: [Double] = candidates.map { dish in - let usage = plan.slots.filter { $0.dishId == dish.id }.count + let usage = plan.slotList.filter { $0.dishId == dish.id }.count let priorityBoost: Double = dish.isPriority ? 2.5 : 1.0 let patternScore = historyScores[dish.id] ?? 1.0 let ratingScore = ratingScores[dish.id] ?? 1.0 diff --git a/MealMood/Services/DeduplicationService.swift b/MealMood/Services/DeduplicationService.swift new file mode 100644 index 0000000..2920e07 --- /dev/null +++ b/MealMood/Services/DeduplicationService.swift @@ -0,0 +1,83 @@ +import Foundation +import SwiftData + +/// CloudKit private-DB sync can materialize duplicates when several devices +/// independently create "the same" entity before their first sync converges: +/// the AppSettings singleton, the default Tags, or the plan for a given week. +/// This runs once per launch and collapses them deterministically, so every +/// device deletes the same losers and the graph converges. +enum DeduplicationService { + + static func run(context: ModelContext) { + dedupeSettings(context: context) + dedupeTags(context: context) + dedupeWeekPlans(context: context) + try? context.save() + } + + /// Keep a single AppSettings: prefer one with onboarding completed, then + /// premium (never drop an entitlement marker), then lowest id. + private static func dedupeSettings(context: ModelContext) { + let all = (try? context.fetch(FetchDescriptor())) ?? [] + guard all.count > 1 else { return } + let keeper = all.sorted { lhs, rhs in + if lhs.onboardingCompleted != rhs.onboardingCompleted { return lhs.onboardingCompleted } + if lhs.isPremium != rhs.isPremium { return lhs.isPremium } + return lhs.persistentModelID.hashValue < rhs.persistentModelID.hashValue + }.first! + for candidate in all where candidate !== keeper { + context.delete(candidate) + } + } + + /// Collapse Tags duplicated by id, and default Tags duplicated by English + /// name (two devices seeding the same defaults with different UUIDs). + /// Dish.tagIds referencing a removed duplicate are remapped to the keeper. + private static func dedupeTags(context: ModelContext) { + let all = (try? context.fetch(FetchDescriptor())) ?? [] + guard all.count > 1 else { return } + + var idMap: [UUID: UUID] = [:] // loser id → keeper id + var seen: [String: Tag] = [:] + for tag in all.sorted(by: { $0.id.uuidString < $1.id.uuidString }) { + let key = tag.isDefault + ? "default|\(tag.nameEN.lowercased())" + : "id|\(tag.id.uuidString)" + if let keeper = seen[key] { + if keeper.id != tag.id { idMap[tag.id] = keeper.id } + context.delete(tag) + } else { + seen[key] = tag + } + } + guard !idMap.isEmpty else { return } + + let dishes = (try? context.fetch(FetchDescriptor())) ?? [] + for dish in dishes { + let remapped = dish.tagIds.map { idMap[$0] ?? $0 } + let unique = Array(NSOrderedSet(array: remapped)) as? [UUID] ?? remapped + if unique != dish.tagIds { + dish.tagIds = unique + } + } + } + + /// Keep one WeekPlan per weekStartDate: the one with most assigned slots + /// (ties: more slots, then lowest id). Losers cascade-delete their slots. + private static func dedupeWeekPlans(context: ModelContext) { + let all = (try? context.fetch(FetchDescriptor())) ?? [] + let grouped = Dictionary(grouping: all, by: \.weekStartDate) + for (_, plans) in grouped where plans.count > 1 { + let keeper = plans.sorted { lhs, rhs in + let lhsAssigned = lhs.slotList.filter { $0.dishId != nil }.count + let rhsAssigned = rhs.slotList.filter { $0.dishId != nil }.count + if lhsAssigned != rhsAssigned { return lhsAssigned > rhsAssigned } + if lhs.slotList.count != rhs.slotList.count { return lhs.slotList.count > rhs.slotList.count } + return lhs.id.uuidString < rhs.id.uuidString + }.first! + for plan in plans where plan !== keeper { + context.delete(plan) + } + } + } +} diff --git a/MealMood/Services/DefaultDataService.swift b/MealMood/Services/DefaultDataService.swift index 0ff4367..2558ccc 100644 --- a/MealMood/Services/DefaultDataService.swift +++ b/MealMood/Services/DefaultDataService.swift @@ -54,7 +54,7 @@ struct DefaultDataService { for mealType in mealTypes { let slot = MealSlot(dayOfWeek: day, mealType: mealType) slot.weekPlan = plan - plan.slots.append(slot) + plan.slotList.append(slot) context.insert(slot) } } diff --git a/MealMood/Services/ICloudSyncService.swift b/MealMood/Services/ICloudSyncService.swift index e371780..030f0f5 100644 --- a/MealMood/Services/ICloudSyncService.swift +++ b/MealMood/Services/ICloudSyncService.swift @@ -14,6 +14,9 @@ final class ICloudSyncService { private init() {} func pullRemoteIfNeeded(context: ModelContext) async { + // 2.0: CloudKit syncs the store directly — applying legacy KV snapshots + // on top would duplicate/fight with it. KV stays for 1.x devices only. + guard !CloudSyncRuntime.isCloudKitActive else { return } store.synchronize() guard let data = store.data(forKey: payloadKey), @@ -35,6 +38,7 @@ final class ICloudSyncService { } func pushLocalSnapshot(context: ModelContext) async { + guard !CloudSyncRuntime.isCloudKitActive else { return } if isApplyingRemote { return } guard let snapshot = makeSnapshot(context: context) else { return } guard let data = try? JSONEncoder().encode(snapshot) else { return } @@ -105,7 +109,7 @@ final class ICloudSyncService { weekStartDate: plan.weekStartDate, createdAt: plan.createdAt, updatedAt: plan.updatedAt, - slots: plan.slots.map { + slots: plan.slotList.map { MealSlotPayload( id: $0.id, dayOfWeek: $0.dayOfWeek, @@ -210,7 +214,7 @@ final class ICloudSyncService { isRuleOverridden: slotPayload.isRuleOverridden ) slot.weekPlan = plan - plan.slots.append(slot) + plan.slotList.append(slot) context.insert(slot) } } diff --git a/MealMood/Services/NotificationService.swift b/MealMood/Services/NotificationService.swift index f0e425e..ec69aaf 100644 --- a/MealMood/Services/NotificationService.swift +++ b/MealMood/Services/NotificationService.swift @@ -64,7 +64,7 @@ final class NotificationService { let center = UNUserNotificationCenter.current() let identifier = "mealmood.next-week-planning" - let isComplete = nextWeekPlan?.slots.allSatisfy { $0.dishId != nil || $0.isEatingOut } ?? false + let isComplete = nextWeekPlan?.slotList.allSatisfy { $0.dishId != nil || $0.isEatingOut } ?? false if isComplete { center.removePendingNotificationRequests(withIdentifiers: [identifier]) } else { @@ -94,7 +94,7 @@ final class NotificationService { let identifier = "mealmood.sunday-planning" // Cancel if next week is already planned - let hasAnySlot = nextWeekPlan?.slots.contains { $0.dishId != nil || $0.isEatingOut } ?? false + let hasAnySlot = nextWeekPlan?.slotList.contains { $0.dishId != nil || $0.isEatingOut } ?? false if hasAnySlot { center.removePendingNotificationRequests(withIdentifiers: [identifier]) return diff --git a/MealMood/Services/WidgetDataStore.swift b/MealMood/Services/WidgetDataStore.swift index 8ca96e7..66ddb16 100644 --- a/MealMood/Services/WidgetDataStore.swift +++ b/MealMood/Services/WidgetDataStore.swift @@ -26,7 +26,7 @@ enum WidgetDataStore { let appDayOfWeek = (weekday + 5) % 7 let meals: [TodayMealData.Meal] = settings.activeMealTypes.map { mealType in - let dishId = plan?.slots.first { + let dishId = plan?.slotList.first { $0.dayOfWeek == appDayOfWeek && $0.mealType == mealType.rawValue }?.dishId let name = dishId.flatMap { id in dishes.first { $0.id == id }?.name } diff --git a/MealMood/ViewModels/DishViewModel.swift b/MealMood/ViewModels/DishViewModel.swift index 96f05a6..9bd551a 100644 --- a/MealMood/ViewModels/DishViewModel.swift +++ b/MealMood/ViewModels/DishViewModel.swift @@ -109,7 +109,7 @@ final class DishViewModel: ObservableObject { func canDelete(currentPlan: WeekPlan?) -> Bool { guard let dish = editingDish, let plan = currentPlan else { return true } - return !plan.slots.contains { $0.dishId == dish.id } + return !plan.slotList.contains { $0.dishId == dish.id } } private func isDishAssignedInCurrentWeek(dishId: UUID, context: ModelContext) -> Bool { @@ -124,6 +124,6 @@ final class DishViewModel: ObservableObject { return false } - return currentPlan.slots.contains { $0.dishId == dishId } + return currentPlan.slotList.contains { $0.dishId == dishId } } } diff --git a/MealMood/ViewModels/HomeViewModel.swift b/MealMood/ViewModels/HomeViewModel.swift index 6d0f7ba..fb8be0b 100644 --- a/MealMood/ViewModels/HomeViewModel.swift +++ b/MealMood/ViewModels/HomeViewModel.swift @@ -128,7 +128,7 @@ final class HomeViewModel: ObservableObject { .sorted { $0.weekStartDate > $1.weekStartDate } .prefix(12) - let emptySlots = plan.slots.filter { $0.dishId == nil && !$0.isEatingOut } + let emptySlots = plan.slotList.filter { $0.dishId == nil && !$0.isEatingOut } let result = AutocompleteEngine.autocomplete( emptySlots: emptySlots, currentPlan: plan, @@ -176,7 +176,7 @@ final class HomeViewModel: ObservableObject { autoAssignedSlotIds = [] captureUndoSnapshot(plan: plan) - for slot in plan.slots { + for slot in plan.slotList { if let eventId = slot.calendarEventId { CalendarService.shared.deleteEvent(eventId: eventId) slot.calendarEventId = nil @@ -278,13 +278,13 @@ final class HomeViewModel: ObservableObject { captureUndoSnapshot(plan: currentPlan) var previousByKey: [SlotKey: MealSlot] = [:] - for slot in previousPlan.slots { + for slot in previousPlan.slotList { previousByKey[SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType)] = slot } let dishIds = Set(allDishes.map(\.id)) var copiedCount = 0 - for slot in currentPlan.slots { + for slot in currentPlan.slotList { if let eventId = slot.calendarEventId { CalendarService.shared.deleteEvent(eventId: eventId) slot.calendarEventId = nil @@ -316,7 +316,7 @@ final class HomeViewModel: ObservableObject { guard canUndo(for: plan) else { return } isApplyingUndo = true - for slot in plan.slots { + for slot in plan.slotList { if let eventId = slot.calendarEventId { CalendarService.shared.deleteEvent(eventId: eventId) } @@ -352,7 +352,7 @@ final class HomeViewModel: ObservableObject { private func captureUndoSnapshot(plan: WeekPlan) { guard !isApplyingUndo else { return } lastWeekStartSnapshot = plan.weekStartDate - lastSlotsSnapshot = Dictionary(uniqueKeysWithValues: plan.slots.map { slot in + lastSlotsSnapshot = Dictionary(uniqueKeysWithValues: plan.slotList.map { slot in (slot.id, SlotSnapshot(dishId: slot.dishId, isRuleOverridden: slot.isRuleOverridden, isEatingOut: slot.isEatingOut)) }) hasUndoSnapshot = true @@ -365,7 +365,7 @@ final class HomeViewModel: ObservableObject { } private func firstFreeSlot(in plan: WeekPlan) -> MealSlot? { - plan.slots + plan.slotList .filter { $0.dishId == nil && !$0.isEatingOut } .sorted { lhs, rhs in if lhs.dayOfWeek != rhs.dayOfWeek { @@ -385,7 +385,7 @@ final class HomeViewModel: ObservableObject { switch settings.syncModeEnum { case .weekComplete: - if plan.slots.allSatisfy({ $0.dishId != nil || $0.isEatingOut }) { + if plan.slotList.allSatisfy({ $0.dishId != nil || $0.isEatingOut }) { let descriptor = FetchDescriptor() let dishes = (try? plan.modelContext?.fetch(descriptor)) ?? [] syncAllAssignedSlotsToCalendar(plan: plan, dishes: dishes, settings: settings) @@ -401,7 +401,7 @@ final class HomeViewModel: ObservableObject { } private func clearCalendarEvents(for plan: WeekPlan) { - for slot in plan.slots { + for slot in plan.slotList { if let eventId = slot.calendarEventId { CalendarService.shared.deleteEvent(eventId: eventId) slot.calendarEventId = nil @@ -411,7 +411,7 @@ final class HomeViewModel: ObservableObject { private func syncAllAssignedSlotsToCalendar(plan: WeekPlan, dishes: [Dish], settings: AppSettings) { let dishById = Dictionary(uniqueKeysWithValues: dishes.map { ($0.id, $0) }) - for slot in plan.slots { + for slot in plan.slotList { guard let dishId = slot.dishId, let dish = dishById[dishId] else { if let eventId = slot.calendarEventId { CalendarService.shared.deleteEvent(eventId: eventId) @@ -499,7 +499,7 @@ final class HomeViewModel: ObservableObject { // 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 { + for slot in plan.slotList { let key = SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType) groupedByKey[key, default: []].append(slot) } @@ -518,7 +518,7 @@ final class HomeViewModel: ObservableObject { CalendarService.shared.deleteEvent(eventId: duplicateEventId) } - plan.slots.removeAll { $0.id == duplicate.id } + plan.slotList.removeAll { $0.id == duplicate.id } context.delete(duplicate) changed = true } @@ -532,21 +532,21 @@ final class HomeViewModel: ObservableObject { } var existingByKey: [SlotKey: MealSlot] = [:] - for slot in plan.slots { + for slot in plan.slotList { let key = SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType) if existingByKey[key] == nil { existingByKey[key] = slot } } - let toDelete = plan.slots.filter { slot in + let toDelete = plan.slotList.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 } + plan.slotList.removeAll { $0.id == slot.id } context.delete(slot) changed = true } @@ -554,7 +554,7 @@ final class HomeViewModel: ObservableObject { for key in desired where existingByKey[key] == nil { let slot = MealSlot(dayOfWeek: key.dayOfWeek, mealType: key.mealType) slot.weekPlan = plan - plan.slots.append(slot) + plan.slotList.append(slot) context.insert(slot) changed = true } diff --git a/MealMood/ViewModels/OnboardingViewModel.swift b/MealMood/ViewModels/OnboardingViewModel.swift index a876f9b..26941da 100644 --- a/MealMood/ViewModels/OnboardingViewModel.swift +++ b/MealMood/ViewModels/OnboardingViewModel.swift @@ -191,7 +191,7 @@ final class OnboardingViewModel: ObservableObject { let plans = (try? context.fetch(FetchDescriptor())) ?? [] guard let plan = plans.first(where: { $0.weekStartDate == weekStart }) else { return 0 } - let emptySlots = plan.slots.filter { $0.dishId == nil && !$0.isEatingOut } + let emptySlots = plan.slotList.filter { $0.dishId == nil && !$0.isEatingOut } guard !emptySlots.isEmpty else { return 0 } let result = AutocompleteEngine.autocomplete( diff --git a/MealMood/ViewModels/SettingsViewModel.swift b/MealMood/ViewModels/SettingsViewModel.swift index 63a0048..437898e 100644 --- a/MealMood/ViewModels/SettingsViewModel.swift +++ b/MealMood/ViewModels/SettingsViewModel.swift @@ -60,7 +60,7 @@ final class SettingsViewModel: ObservableObject { func updateCalendarEvents(settings: AppSettings, plan: WeekPlan?) { guard let plan = plan, settings.syncEnabled else { return } CalendarService.shared.updateEventsTime( - slots: plan.slots, + slots: plan.slotList, weekStartDate: plan.weekStartDate, settings: settings ) diff --git a/MealMood/Views/Dishes/DishListView.swift b/MealMood/Views/Dishes/DishListView.swift index 6cd4e42..1ef425c 100644 --- a/MealMood/Views/Dishes/DishListView.swift +++ b/MealMood/Views/Dishes/DishListView.swift @@ -149,7 +149,7 @@ struct DishListView: View { for index in offsets { let dish = dishes[index] - if currentWeekPlan?.slots.contains(where: { $0.dishId == dish.id }) == true { + if currentWeekPlan?.slotList.contains(where: { $0.dishId == dish.id }) == true { showDeleteBlockedAlert = true continue } diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index 3c95291..15efb7c 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -264,7 +264,7 @@ struct HomeView: View { } ) - let filledCount = plan.slots.filter { $0.dishId != nil || $0.isEatingOut }.count + let filledCount = plan.slotList.filter { $0.dishId != nil || $0.isEatingOut }.count if filledCount > 0 { exportCallout(plan: plan, settings: settings) } @@ -273,7 +273,7 @@ struct HomeView: View { weekRatingRow(plan: plan) } - let emptyCount = plan.slots.filter { $0.dishId == nil && !$0.isEatingOut }.count + let emptyCount = plan.slotList.filter { $0.dishId == nil && !$0.isEatingOut }.count if viewModel.canEditCurrentWeek && !dishes.isEmpty && emptyCount > 0 { autoAssignBanner(emptyCount: emptyCount, plan: plan, settings: settings) } @@ -410,12 +410,12 @@ struct HomeView: View { ) ) { if let slotId = selectedEmptySlotId, - plan.slots.contains(where: { $0.id == slotId }) { + plan.slotList.contains(where: { $0.id == slotId }) { SlotDishPickerSheet( dishes: dishes, tags: tags, onPickDish: { dish in - guard let freshSlot = plan.slots.first(where: { $0.id == slotId }) else { + guard let freshSlot = plan.slotList.first(where: { $0.id == slotId }) else { selectedEmptySlotId = nil return } @@ -440,7 +440,7 @@ struct HomeView: View { } }, onEatingOut: { - guard let freshSlot = plan.slots.first(where: { $0.id == slotId }) else { + guard let freshSlot = plan.slotList.first(where: { $0.id == slotId }) else { selectedEmptySlotId = nil return } @@ -459,12 +459,12 @@ struct HomeView: View { ) ) { if let slotId = selectedFilledSlotId, - plan.slots.contains(where: { $0.id == slotId }) { + plan.slotList.contains(where: { $0.id == slotId }) { SlotDishPickerSheet( dishes: dishes, tags: tags, onPickDish: { dish in - guard let freshSlot = plan.slots.first(where: { $0.id == slotId }) else { + guard let freshSlot = plan.slotList.first(where: { $0.id == slotId }) else { selectedFilledSlotId = nil return } @@ -714,7 +714,7 @@ struct HomeView: View { dishes: dishes, tags: tags, language: settings.languageEnum.resolved(), - usedDishIds: Set(plan.slots.compactMap(\.dishId)), + usedDishIds: Set(plan.slotList.compactMap(\.dishId)), usageRanking: dishUsageCounts, onAddDish: { viewModel.showDishForm = true }, onQuickAssignDish: { dish in @@ -730,7 +730,7 @@ struct HomeView: View { editingDish = dish }, onDeleteDish: { dish in - let isAssignedInCurrentWeek = plan.slots.contains { $0.dishId == dish.id } + let isAssignedInCurrentWeek = plan.slotList.contains { $0.dishId == dish.id } if isAssignedInCurrentWeek { viewModel.toastMessage = localizedString("dish_delete_blocked_message", language: settings.languageEnum.resolved()) viewModel.showToast = true @@ -775,13 +775,13 @@ struct HomeView: View { } private func isWeekComplete(plan: WeekPlan) -> Bool { - plan.slots.allSatisfy { $0.dishId != nil || $0.isEatingOut } + plan.slotList.allSatisfy { $0.dishId != nil || $0.isEatingOut } } private var dishUsageCounts: [UUID: Int] { var counts: [UUID: Int] = [:] for plan in weekPlans { - for slot in plan.slots { + for slot in plan.slotList { if let dishId = slot.dishId { counts[dishId, default: 0] += 1 } @@ -910,14 +910,14 @@ struct HomeView: View { private func previousWeekHasMenu() -> Bool { guard let previous = fetchWeekPlan(for: viewModel.currentWeekStart.addingDays(-7)) else { return false } - return previous.slots.contains { $0.dishId != nil } + return previous.slotList.contains { $0.dishId != nil } } /// Copies the previous week, asking to confirm first only when the current /// week already has dishes that would be overwritten. private func requestCopyPreviousWeek(plan: WeekPlan, settings: AppSettings, source: String) { copyPreviousSource = source - if plan.slots.contains(where: { $0.dishId != nil }) { + if plan.slotList.contains(where: { $0.dishId != nil }) { showCopyPreviousConfirm = true } else { copyFromPreviousWeek(currentPlan: plan, settings: settings) @@ -994,10 +994,10 @@ struct HomeView: View { private func preferredWeekPlan(from plans: [WeekPlan]) -> WeekPlan? { plans.max { lhs, rhs in - let lhsAssigned = lhs.slots.filter { $0.dishId != nil }.count - let rhsAssigned = rhs.slots.filter { $0.dishId != nil }.count + let lhsAssigned = lhs.slotList.filter { $0.dishId != nil }.count + let rhsAssigned = rhs.slotList.filter { $0.dishId != nil }.count if lhsAssigned != rhsAssigned { return lhsAssigned < rhsAssigned } - if lhs.slots.count != rhs.slots.count { return lhs.slots.count < rhs.slots.count } + if lhs.slotList.count != rhs.slotList.count { return lhs.slotList.count < rhs.slotList.count } return lhs.updatedAt < rhs.updatedAt } } @@ -1078,7 +1078,7 @@ struct HomeView: View { private func evaluateReviewPrompt() { let descriptor = FetchDescriptor() guard let plans = try? context.fetch(descriptor) else { return } - let completedWeeks = plans.filter { !$0.slots.isEmpty && $0.slots.allSatisfy { $0.dishId != nil } }.count + let completedWeeks = plans.filter { !$0.slotList.isEmpty && $0.slotList.allSatisfy { $0.dishId != nil } }.count guard ReviewPromptService.shared.shouldShowFunnelAfterWeekCompletion(completedWeeks: completedWeeks) else { return } ReviewPromptService.shared.markFunnelShown(completedWeeks: completedWeeks) showReviewSentimentPrompt = true @@ -1092,7 +1092,7 @@ struct HomeView: View { // instead of being asked. Reuses the home's autoComplete choreography. if UserDefaults.standard.bool(forKey: OnboardingViewModel.pendingAutoFillOnLaunchKey) { UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingAutoFillOnLaunchKey) - if !dishes.isEmpty && plan.slots.contains(where: { $0.dishId == nil && !$0.isEatingOut }) { + if !dishes.isEmpty && plan.slotList.contains(where: { $0.dishId == nil && !$0.isEatingOut }) { viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings, allPlans: weekPlans) } schedulePostOnboardingPremiumPromptIfNeeded(settings: settings) @@ -1100,7 +1100,7 @@ struct HomeView: View { } let shouldAskAutoAssign = UserDefaults.standard.bool(forKey: OnboardingViewModel.pendingAutoAssignPromptKey) - if shouldAskAutoAssign && plan.slots.contains(where: { $0.dishId == nil }) { + if shouldAskAutoAssign && plan.slotList.contains(where: { $0.dishId == nil }) { showPostOnboardingAutoAssignPrompt = true return } @@ -1162,7 +1162,7 @@ private struct RuleViolationsPanelSheet: View { } else { List { ForEach(violations, id: \.slotId) { violation in - if let slot = plan.slots.first(where: { $0.id == violation.slotId }) { + if let slot = plan.slotList.first(where: { $0.id == violation.slotId }) { ViolationRow( violation: violation, settings: settings, @@ -1496,7 +1496,7 @@ private struct MonthlyHistoryView: View { Text(weekLabel(for: plan.weekStartDate)) .font(.mealMoodBodyBold) .foregroundColor(.mealMoodTextPrimary) - Text(plan.slots.allSatisfy { $0.dishId != nil } ? String(localized: "history_week_complete") : String(localized: "history_week_incomplete")) + Text(plan.slotList.allSatisfy { $0.dishId != nil } ? String(localized: "history_week_complete") : String(localized: "history_week_incomplete")) .font(.mealMoodCaption) .foregroundColor(.mealMoodTextSecondary) } diff --git a/MealMood/Views/Home/WeekCalendarView.swift b/MealMood/Views/Home/WeekCalendarView.swift index 6a1bf36..a8e5528 100644 --- a/MealMood/Views/Home/WeekCalendarView.swift +++ b/MealMood/Views/Home/WeekCalendarView.swift @@ -207,7 +207,7 @@ struct WeekCalendarView: View { if let cached = displaySlotsByKey[key] { return cached } - let matching = plan.slots.filter { $0.dayOfWeek == day && $0.mealType == mealType.rawValue } + let matching = plan.slotList.filter { $0.dayOfWeek == day && $0.mealType == mealType.rawValue } guard !matching.isEmpty else { return nil } let preferred = MealSlot.preferredForDuplicateResolution(matching) return DisplaySlot( @@ -246,7 +246,7 @@ struct WeekCalendarView: View { viewModel.confirmInvalidDrop(dish, to: targetSlot, plan: plan, settings: settings) return true case .slot(let sourceSlotId): - guard let sourceSlot = plan.slots.first(where: { $0.id == sourceSlotId }) else { return false } + guard let sourceSlot = plan.slotList.first(where: { $0.id == sourceSlotId }) else { return false } viewModel.moveOrSwapDish( from: sourceSlot, to: targetSlot, @@ -358,7 +358,7 @@ struct WeekCalendarView: View { private func refreshDisplaySlots() { var resolved: [String: DisplaySlot] = [:] - let grouped = Dictionary(grouping: plan.slots, by: { slotKey(day: $0.dayOfWeek, mealType: $0.mealType) }) + let grouped = Dictionary(grouping: plan.slotList, by: { slotKey(day: $0.dayOfWeek, mealType: $0.mealType) }) for (key, candidates) in grouped { guard !candidates.isEmpty else { continue } let preferred = MealSlot.preferredForDuplicateResolution(candidates) @@ -387,10 +387,10 @@ struct WeekCalendarView: View { } private func liveSlot(for display: DisplaySlot) -> MealSlot? { - if let exact = plan.slots.first(where: { $0.id == display.slotId }) { + if let exact = plan.slotList.first(where: { $0.id == display.slotId }) { return exact } - let matching = plan.slots.filter { $0.dayOfWeek == display.dayOfWeek && $0.mealType == display.mealType } + let matching = plan.slotList.filter { $0.dayOfWeek == display.dayOfWeek && $0.mealType == display.mealType } guard !matching.isEmpty else { return nil } return MealSlot.preferredForDuplicateResolution(matching) } diff --git a/MealMood/Views/Home/WeekPlanShareView.swift b/MealMood/Views/Home/WeekPlanShareView.swift index 2d18faf..54b5bf5 100644 --- a/MealMood/Views/Home/WeekPlanShareView.swift +++ b/MealMood/Views/Home/WeekPlanShareView.swift @@ -507,7 +507,7 @@ struct WeekPlanShareView: View { } private func dishName(day: Int, mealType: MealType) -> String { - let slot = plan.slots.first { $0.dayOfWeek == day && $0.mealType == mealType.rawValue } + let slot = plan.slotList.first { $0.dayOfWeek == day && $0.mealType == mealType.rawValue } guard let slot else { return String(localized: "share_slot_empty") } if slot.isEatingOut { return String(localized: "slot_eating_out") } guard let dishId = slot.dishId, let dish = dishes.first(where: { $0.id == dishId }) else { diff --git a/MealMood/Views/Shopping/ShoppingListView.swift b/MealMood/Views/Shopping/ShoppingListView.swift index d8b015b..025f92c 100644 --- a/MealMood/Views/Shopping/ShoppingListView.swift +++ b/MealMood/Views/Shopping/ShoppingListView.swift @@ -29,7 +29,7 @@ struct ShoppingListView: View { private var plannedDishes: [Dish] { var seen = Set() var result: [Dish] = [] - let sortedSlots = plan.slots.sorted { + let sortedSlots = plan.slotList.sorted { ($0.dayOfWeek, $0.mealType) < ($1.dayOfWeek, $1.mealType) } for slot in sortedSlots { diff --git a/MealMood/Views/Stats/StatsView.swift b/MealMood/Views/Stats/StatsView.swift index e00ce85..4b61314 100644 --- a/MealMood/Views/Stats/StatsView.swift +++ b/MealMood/Views/Stats/StatsView.swift @@ -130,14 +130,14 @@ struct StatsView: View { // MARK: - Computed stats private var plannedWeeks: [WeekPlan] { - weekPlans.filter { plan in plan.slots.contains { $0.dishId != nil } } + weekPlans.filter { plan in plan.slotList.contains { $0.dishId != nil } } } private var weeksWithAnyDish: Int { plannedWeeks.count } private var completeWeeks: Int { weekPlans.filter { plan in - !plan.slots.isEmpty && plan.slots.allSatisfy { $0.dishId != nil || $0.isEatingOut } + !plan.slotList.isEmpty && plan.slotList.allSatisfy { $0.dishId != nil || $0.isEatingOut } }.count } @@ -170,7 +170,7 @@ struct StatsView: View { private var dishUsage: [UUID: Int] { var counts: [UUID: Int] = [:] for plan in weekPlans { - for slot in plan.slots { + for slot in plan.slotList { if let id = slot.dishId { counts[id, default: 0] += 1 } } } @@ -196,7 +196,7 @@ struct StatsView: View { let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) }) var counts: [UUID: Int] = [:] for plan in weekPlans { - for slot in plan.slots { + for slot in plan.slotList { guard let dishId = slot.dishId, let dish = dishMap[dishId] else { continue } for tagId in dish.tagIds { counts[tagId, default: 0] += 1 } } diff --git a/MealMoodTests/AutocompleteEngineTests.swift b/MealMoodTests/AutocompleteEngineTests.swift index d75d09c..be6a914 100644 --- a/MealMoodTests/AutocompleteEngineTests.swift +++ b/MealMoodTests/AutocompleteEngineTests.swift @@ -19,7 +19,7 @@ final class AutocompleteEngineTests: XCTestCase { let plan = WeekPlan(weekStartDate: Date().startOfWeek()) let mondayDinner = MealSlot(dayOfWeek: 0, mealType: MealType.dinner.rawValue, dishId: dishA.id) let tuesdayDinner = MealSlot(dayOfWeek: 1, mealType: MealType.dinner.rawValue, dishId: nil) - plan.slots = [mondayDinner, tuesdayDinner] + plan.slotList = [mondayDinner, tuesdayDinner] let isValid = AutocompleteEngine.validateDrop( dish: dishB, @@ -47,7 +47,7 @@ final class AutocompleteEngineTests: XCTestCase { let mondayDinner = MealSlot(dayOfWeek: 0, mealType: MealType.dinner.rawValue, dishId: fishDish.id) let tuesdayDinner = MealSlot(dayOfWeek: 1, mealType: MealType.dinner.rawValue, dishId: nil) - plan.slots = [mondayDinner, tuesdayDinner] + plan.slotList = [mondayDinner, tuesdayDinner] let result = AutocompleteEngine.autocomplete( emptySlots: [tuesdayDinner],