2.0: CloudKit private-database sync replaces KV snapshot sync
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG
This commit is contained in:
@@ -36,6 +36,7 @@
|
|||||||
5E7322F3F3624211985F2D99 /* ICloudSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED090ADA1FB259AD2AFEDE2 /* ICloudSyncService.swift */; };
|
5E7322F3F3624211985F2D99 /* ICloudSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED090ADA1FB259AD2AFEDE2 /* ICloudSyncService.swift */; };
|
||||||
5ECA2501EC257777DBE670AD /* EmptySlotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68E5C82608F60FF8798BB81 /* EmptySlotView.swift */; };
|
5ECA2501EC257777DBE670AD /* EmptySlotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68E5C82608F60FF8798BB81 /* EmptySlotView.swift */; };
|
||||||
6553473B007AFD04079878C6 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F0A5C2FF1B797A4BB69B39 /* ContentView.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 */; };
|
670A11A9850B321D9FF76B41 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D718B2E4F7E6D22E502F0A6 /* OnboardingView.swift */; };
|
||||||
6C808D1B6E8534FABC4A4053 /* WeekPlanShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E113A29DEC0551C41AB7EBB /* WeekPlanShareView.swift */; };
|
6C808D1B6E8534FABC4A4053 /* WeekPlanShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E113A29DEC0551C41AB7EBB /* WeekPlanShareView.swift */; };
|
||||||
717C67C9A7F48587667B5AE2 /* Dish.swift in Sources */ = {isa = PBXBuildFile; fileRef = 623EDDC4CD134883FC9D242D /* Dish.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 = "<group>"; };
|
965555A7774FF7EB975F0A07 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
9F86EB5DDB126D73486FD956 /* MealMood.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MealMood.entitlements; sourceTree = "<group>"; };
|
9F86EB5DDB126D73486FD956 /* MealMood.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MealMood.entitlements; sourceTree = "<group>"; };
|
||||||
9FBA11C8A81D5C0CA8A3C13D /* MealWindowsStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealWindowsStepView.swift; sourceTree = "<group>"; };
|
9FBA11C8A81D5C0CA8A3C13D /* MealWindowsStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealWindowsStepView.swift; sourceTree = "<group>"; };
|
||||||
|
A1C3CBAFE191AD6643F861B7 /* DeduplicationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeduplicationService.swift; sourceTree = "<group>"; };
|
||||||
A23267BAA116E1214870416C /* WeekPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekPlan.swift; sourceTree = "<group>"; };
|
A23267BAA116E1214870416C /* WeekPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekPlan.swift; sourceTree = "<group>"; };
|
||||||
A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackStore.swift; sourceTree = "<group>"; };
|
A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackStore.swift; sourceTree = "<group>"; };
|
||||||
A6FC2153490D53B80DBDC034 /* WidgetDataStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = "<group>"; };
|
A6FC2153490D53B80DBDC034 /* WidgetDataStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = "<group>"; };
|
||||||
@@ -519,6 +521,7 @@
|
|||||||
A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */,
|
A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */,
|
||||||
71F0ADA350289AE410D572A7 /* IngredientGenerator.swift */,
|
71F0ADA350289AE410D572A7 /* IngredientGenerator.swift */,
|
||||||
5C512046B1410E236A3F6286 /* ShoppingListService.swift */,
|
5C512046B1410E236A3F6286 /* ShoppingListService.swift */,
|
||||||
|
A1C3CBAFE191AD6643F861B7 /* DeduplicationService.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -761,6 +764,7 @@
|
|||||||
B8FDF31D3BD7DD7220CE21A3 /* ShoppingItem.swift in Sources */,
|
B8FDF31D3BD7DD7220CE21A3 /* ShoppingItem.swift in Sources */,
|
||||||
44E43D5F00D1B513F9BABE80 /* ShoppingListService.swift in Sources */,
|
44E43D5F00D1B513F9BABE80 /* ShoppingListService.swift in Sources */,
|
||||||
57F1EA6B5E3AF88A61C48354 /* ShoppingListView.swift in Sources */,
|
57F1EA6B5E3AF88A61C48354 /* ShoppingListView.swift in Sources */,
|
||||||
|
6685C591F837FAFB29A322F7 /* DeduplicationService.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ struct ContentView: View {
|
|||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
private func initializeAppIfNeeded() {
|
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 {
|
if allSettings.isEmpty {
|
||||||
DefaultDataService.createDefaultSettings(context: context)
|
DefaultDataService.createDefaultSettings(context: context)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ import FirebaseCrashlytics
|
|||||||
import GoogleMobileAds
|
import GoogleMobileAds
|
||||||
#endif
|
#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
|
@main
|
||||||
struct MealMoodApp: App {
|
struct MealMoodApp: App {
|
||||||
private let modelContainer: ModelContainer = {
|
private let modelContainer: ModelContainer = {
|
||||||
@@ -17,6 +25,20 @@ struct MealMoodApp: App {
|
|||||||
MealSlot.self,
|
MealSlot.self,
|
||||||
ShoppingItem.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)
|
let configuration = ModelConfiguration(cloudKitDatabase: .none)
|
||||||
do {
|
do {
|
||||||
return try ModelContainer(for: schema, configurations: [configuration])
|
return try ModelContainer(for: schema, configurations: [configuration])
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
|
// CloudKit-compatible (2.0): no unique constraints, inline defaults everywhere.
|
||||||
@Model
|
@Model
|
||||||
final class AppSettings {
|
final class AppSettings {
|
||||||
var mealWindows: String // "dinnerOnly", "lunchOnly", "both"
|
var mealWindows: String = "dinnerOnly" // legacy: "dinnerOnly", "lunchOnly", "both"
|
||||||
var includeWeekends: Bool = true
|
var includeWeekends: Bool = true
|
||||||
var language: String // "spanish", "english"
|
var language: String = "system"
|
||||||
|
|
||||||
/// Comma-separated MealType raw values, in chronological order (2.0).
|
/// Comma-separated MealType raw values, in chronological order (2.0).
|
||||||
/// nil = derive from the legacy `mealWindows` field (pre-2.0 installs).
|
/// nil = derive from the legacy `mealWindows` field (pre-2.0 installs).
|
||||||
@@ -14,12 +15,12 @@ final class AppSettings {
|
|||||||
var calendarId: String?
|
var calendarId: String?
|
||||||
var syncEnabled: Bool = false
|
var syncEnabled: Bool = false
|
||||||
var syncMode: String? // "weekComplete", "manual"
|
var syncMode: String? // "weekComplete", "manual"
|
||||||
var lunchTime: Date
|
var lunchTime: Date = AppSettings.defaultTime(hour: 14)
|
||||||
var dinnerTime: Date
|
var dinnerTime: Date = AppSettings.defaultTime(hour: 21)
|
||||||
var breakfastTime: Date? // optional: pre-2.0 stores lack it
|
var breakfastTime: Date? // optional: pre-2.0 stores lack it
|
||||||
var snackTime: Date?
|
var snackTime: Date?
|
||||||
var eventDuration: Int
|
var eventDuration: Int = 60
|
||||||
var eventPrefix: String
|
var eventPrefix: String = "🍽️"
|
||||||
var reminderMinutesBefore: Int?
|
var reminderMinutesBefore: Int?
|
||||||
var iCloudSyncEnabled: Bool?
|
var iCloudSyncEnabled: Bool?
|
||||||
var weekExportStyle: String?
|
var weekExportStyle: String?
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
|
// CloudKit-compatible (2.0): no unique constraints, inline defaults everywhere.
|
||||||
@Model
|
@Model
|
||||||
final class Dish {
|
final class Dish {
|
||||||
@Attribute(.unique) var id: UUID
|
var id: UUID = UUID()
|
||||||
var name: String
|
var name: String = ""
|
||||||
var descriptionText: String?
|
var descriptionText: String?
|
||||||
var tagIds: [UUID]
|
var tagIds: [UUID] = []
|
||||||
var createdAt: Date
|
var createdAt: Date = Date()
|
||||||
var isPriority: Bool = false
|
var isPriority: Bool = false
|
||||||
|
|
||||||
/// Optional ingredient lines (one per entry, e.g. "200g spaghetti").
|
/// Optional ingredient lines (one per entry, e.g. "200g spaghetti").
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
|
// CloudKit-compatible (2.0): no unique constraints, inline defaults everywhere.
|
||||||
@Model
|
@Model
|
||||||
final class MealSlot {
|
final class MealSlot {
|
||||||
@Attribute(.unique) var id: UUID
|
var id: UUID = UUID()
|
||||||
var dayOfWeek: Int // 0=Monday, 6=Sunday
|
var dayOfWeek: Int = 0 // 0=Monday, 6=Sunday
|
||||||
var mealType: String // "lunch", "dinner"
|
var mealType: String = MealType.dinner.rawValue
|
||||||
var dishId: UUID?
|
var dishId: UUID?
|
||||||
var calendarEventId: String?
|
var calendarEventId: String?
|
||||||
var isRuleOverridden: Bool = false
|
var isRuleOverridden: Bool = false
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ import SwiftData
|
|||||||
/// One line of the weekly shopping list. Items are either derived from a
|
/// 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
|
/// planned dish's ingredients (`dishId` set) or added free-hand by the user
|
||||||
/// (`dishId == nil`). Check-off state persists across app launches.
|
/// (`dishId == nil`). Check-off state persists across app launches.
|
||||||
|
// CloudKit-compatible (2.0): no unique constraints, inline defaults everywhere.
|
||||||
@Model
|
@Model
|
||||||
final class ShoppingItem {
|
final class ShoppingItem {
|
||||||
@Attribute(.unique) var id: UUID
|
var id: UUID = UUID()
|
||||||
var weekStartDate: Date
|
var weekStartDate: Date = Date()
|
||||||
var title: String
|
var title: String = ""
|
||||||
var dishId: UUID?
|
var dishId: UUID?
|
||||||
var isChecked: Bool = false
|
var isChecked: Bool = false
|
||||||
var sortOrder: Int = 0
|
var sortOrder: Int = 0
|
||||||
var createdAt: Date
|
var createdAt: Date = Date()
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
|
// CloudKit-compatible (2.0): no unique constraints, inline defaults everywhere.
|
||||||
@Model
|
@Model
|
||||||
final class Tag {
|
final class Tag {
|
||||||
@Attribute(.unique) var id: UUID
|
var id: UUID = UUID()
|
||||||
var name: String
|
var name: String = ""
|
||||||
var nameEN: String
|
var nameEN: String = ""
|
||||||
var color: String
|
var color: String = ""
|
||||||
var maxPerWeek: Int?
|
var maxPerWeek: Int?
|
||||||
var noConsecutive: Bool = false
|
var noConsecutive: Bool = false
|
||||||
var noDuplicateInDay: Bool = false
|
var noDuplicateInDay: Bool = false
|
||||||
var mealTypeRestriction: String? // "lunch", "dinner", nil
|
var mealTypeRestriction: String? // "lunch", "dinner", nil
|
||||||
var dayRestriction: String? // nil = any day, "weekdays" = Mon-Fri, "weekend" = Sat-Sun
|
var dayRestriction: String? // nil = any day, "weekdays" = Mon-Fri, "weekend" = Sat-Sun
|
||||||
var isDefault: Bool = true
|
var isDefault: Bool = true
|
||||||
var sortOrder: Int
|
var sortOrder: Int = 0
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftData
|
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
|
@Model
|
||||||
final class WeekPlan {
|
final class WeekPlan {
|
||||||
@Attribute(.unique) var id: UUID
|
var id: UUID = UUID()
|
||||||
var weekStartDate: Date
|
var weekStartDate: Date = Date()
|
||||||
var createdAt: Date
|
var createdAt: Date = Date()
|
||||||
var updatedAt: Date
|
var updatedAt: Date = Date()
|
||||||
var userRating: Int = 0 // 0 = unrated, 1 = liked, -1 = disliked
|
var userRating: Int = 0 // 0 = unrated, 1 = liked, -1 = disliked
|
||||||
|
|
||||||
@Relationship(deleteRule: .cascade)
|
@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(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2.0</string>
|
<string>2.0</string>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>remote-notification</string>
|
||||||
|
</array>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>iCloud.com.alexandrev.mealmood</string>
|
<string>iCloud.com.alexandrev.mealmood</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>com.apple.developer.icloud-services</key>
|
||||||
|
<array>
|
||||||
|
<string>CloudKit</string>
|
||||||
|
</array>
|
||||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ struct AutocompleteEngine {
|
|||||||
var pendingSlots = emptySlots.filter { !$0.isEatingOut }
|
var pendingSlots = emptySlots.filter { !$0.isEatingOut }
|
||||||
|
|
||||||
// MRV: at each step pick the slot with fewest valid candidates first.
|
// 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.
|
// counts automatically reflect the growing set of committed dishes.
|
||||||
while !pendingSlots.isEmpty {
|
while !pendingSlots.isEmpty {
|
||||||
// Score each remaining slot by strict candidate count
|
// Score each remaining slot by strict candidate count
|
||||||
@@ -78,7 +78,7 @@ struct AutocompleteEngine {
|
|||||||
|
|
||||||
static func findViolations(plan: WeekPlan, allDishes: [Dish], allTags: [Tag]) -> [RuleViolation] {
|
static func findViolations(plan: WeekPlan, allDishes: [Dish], allTags: [Tag]) -> [RuleViolation] {
|
||||||
let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) })
|
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,
|
guard let dishId = slot.dishId,
|
||||||
let dish = dishMap[dishId],
|
let dish = dishMap[dishId],
|
||||||
slot.isRuleOverridden,
|
slot.isRuleOverridden,
|
||||||
@@ -119,7 +119,7 @@ struct AutocompleteEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func violatesDefaultNoRepeatRule(dish: Dish, slot: MealSlot, plan: WeekPlan) -> Bool {
|
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(
|
private static func violatesExplicitRules(
|
||||||
@@ -134,7 +134,7 @@ struct AutocompleteEngine {
|
|||||||
|
|
||||||
if let maxPerWeek = tag.maxPerWeek {
|
if let maxPerWeek = tag.maxPerWeek {
|
||||||
var count = 0
|
var count = 0
|
||||||
for s in plan.slots {
|
for s in plan.slotList {
|
||||||
guard let did = s.dishId, let d = dishMap[did] else { continue }
|
guard let did = s.dishId, let d = dishMap[did] else { continue }
|
||||||
if d.tagIds.contains(tagId) { count += 1 }
|
if d.tagIds.contains(tagId) { count += 1 }
|
||||||
}
|
}
|
||||||
@@ -149,7 +149,7 @@ struct AutocompleteEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if tag.noDuplicateInDay {
|
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 }
|
guard let did = sdSlot.dishId, let d = dishMap[did] else { continue }
|
||||||
if d.tagIds.contains(tagId) { return true }
|
if d.tagIds.contains(tagId) { return true }
|
||||||
}
|
}
|
||||||
@@ -173,11 +173,11 @@ struct AutocompleteEngine {
|
|||||||
private static func adjacentSlots(of slot: MealSlot, in plan: WeekPlan) -> [MealSlot] {
|
private static func adjacentSlots(of slot: MealSlot, in plan: WeekPlan) -> [MealSlot] {
|
||||||
var result: [MealSlot] = []
|
var result: [MealSlot] = []
|
||||||
if slot.dayOfWeek > 0,
|
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)
|
result.append(prev)
|
||||||
}
|
}
|
||||||
if slot.dayOfWeek < 6,
|
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)
|
result.append(next)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -201,7 +201,7 @@ struct AutocompleteEngine {
|
|||||||
// Week offsets: how many weeks ago did this dish appear?
|
// Week offsets: how many weeks ago did this dish appear?
|
||||||
var offsets: [Int] = []
|
var offsets: [Int] = []
|
||||||
for plan in recentPlans {
|
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
|
let diff = calendar.dateComponents([.weekOfYear], from: plan.weekStartDate, to: currentWeekStart).weekOfYear ?? 0
|
||||||
if diff > 0 { offsets.append(diff) }
|
if diff > 0 { offsets.append(diff) }
|
||||||
}
|
}
|
||||||
@@ -261,7 +261,7 @@ struct AutocompleteEngine {
|
|||||||
for dish in dishes {
|
for dish in dishes {
|
||||||
var liked = 0, disliked = 0
|
var liked = 0, disliked = 0
|
||||||
for plan in ratedPlans {
|
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 }
|
if plan.userRating > 0 { liked += 1 } else { disliked += 1 }
|
||||||
}
|
}
|
||||||
guard liked + disliked > 0 else { continue }
|
guard liked + disliked > 0 else { continue }
|
||||||
@@ -283,7 +283,7 @@ struct AutocompleteEngine {
|
|||||||
) -> Dish? {
|
) -> Dish? {
|
||||||
guard !candidates.isEmpty else { return nil }
|
guard !candidates.isEmpty else { return nil }
|
||||||
let weights: [Double] = candidates.map { dish in
|
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 priorityBoost: Double = dish.isPriority ? 2.5 : 1.0
|
||||||
let patternScore = historyScores[dish.id] ?? 1.0
|
let patternScore = historyScores[dish.id] ?? 1.0
|
||||||
let ratingScore = ratingScores[dish.id] ?? 1.0
|
let ratingScore = ratingScores[dish.id] ?? 1.0
|
||||||
|
|||||||
@@ -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<AppSettings>())) ?? []
|
||||||
|
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<Tag>())) ?? []
|
||||||
|
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<Dish>())) ?? []
|
||||||
|
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<WeekPlan>())) ?? []
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ struct DefaultDataService {
|
|||||||
for mealType in mealTypes {
|
for mealType in mealTypes {
|
||||||
let slot = MealSlot(dayOfWeek: day, mealType: mealType)
|
let slot = MealSlot(dayOfWeek: day, mealType: mealType)
|
||||||
slot.weekPlan = plan
|
slot.weekPlan = plan
|
||||||
plan.slots.append(slot)
|
plan.slotList.append(slot)
|
||||||
context.insert(slot)
|
context.insert(slot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ final class ICloudSyncService {
|
|||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
func pullRemoteIfNeeded(context: ModelContext) async {
|
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()
|
store.synchronize()
|
||||||
|
|
||||||
guard let data = store.data(forKey: payloadKey),
|
guard let data = store.data(forKey: payloadKey),
|
||||||
@@ -35,6 +38,7 @@ final class ICloudSyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func pushLocalSnapshot(context: ModelContext) async {
|
func pushLocalSnapshot(context: ModelContext) async {
|
||||||
|
guard !CloudSyncRuntime.isCloudKitActive else { return }
|
||||||
if isApplyingRemote { return }
|
if isApplyingRemote { return }
|
||||||
guard let snapshot = makeSnapshot(context: context) else { return }
|
guard let snapshot = makeSnapshot(context: context) else { return }
|
||||||
guard let data = try? JSONEncoder().encode(snapshot) else { return }
|
guard let data = try? JSONEncoder().encode(snapshot) else { return }
|
||||||
@@ -105,7 +109,7 @@ final class ICloudSyncService {
|
|||||||
weekStartDate: plan.weekStartDate,
|
weekStartDate: plan.weekStartDate,
|
||||||
createdAt: plan.createdAt,
|
createdAt: plan.createdAt,
|
||||||
updatedAt: plan.updatedAt,
|
updatedAt: plan.updatedAt,
|
||||||
slots: plan.slots.map {
|
slots: plan.slotList.map {
|
||||||
MealSlotPayload(
|
MealSlotPayload(
|
||||||
id: $0.id,
|
id: $0.id,
|
||||||
dayOfWeek: $0.dayOfWeek,
|
dayOfWeek: $0.dayOfWeek,
|
||||||
@@ -210,7 +214,7 @@ final class ICloudSyncService {
|
|||||||
isRuleOverridden: slotPayload.isRuleOverridden
|
isRuleOverridden: slotPayload.isRuleOverridden
|
||||||
)
|
)
|
||||||
slot.weekPlan = plan
|
slot.weekPlan = plan
|
||||||
plan.slots.append(slot)
|
plan.slotList.append(slot)
|
||||||
context.insert(slot)
|
context.insert(slot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ final class NotificationService {
|
|||||||
let center = UNUserNotificationCenter.current()
|
let center = UNUserNotificationCenter.current()
|
||||||
let identifier = "mealmood.next-week-planning"
|
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 {
|
if isComplete {
|
||||||
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
||||||
} else {
|
} else {
|
||||||
@@ -94,7 +94,7 @@ final class NotificationService {
|
|||||||
let identifier = "mealmood.sunday-planning"
|
let identifier = "mealmood.sunday-planning"
|
||||||
|
|
||||||
// Cancel if next week is already planned
|
// 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 {
|
if hasAnySlot {
|
||||||
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ enum WidgetDataStore {
|
|||||||
let appDayOfWeek = (weekday + 5) % 7
|
let appDayOfWeek = (weekday + 5) % 7
|
||||||
|
|
||||||
let meals: [TodayMealData.Meal] = settings.activeMealTypes.map { mealType in
|
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
|
$0.dayOfWeek == appDayOfWeek && $0.mealType == mealType.rawValue
|
||||||
}?.dishId
|
}?.dishId
|
||||||
let name = dishId.flatMap { id in dishes.first { $0.id == id }?.name }
|
let name = dishId.flatMap { id in dishes.first { $0.id == id }?.name }
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ final class DishViewModel: ObservableObject {
|
|||||||
|
|
||||||
func canDelete(currentPlan: WeekPlan?) -> Bool {
|
func canDelete(currentPlan: WeekPlan?) -> Bool {
|
||||||
guard let dish = editingDish, let plan = currentPlan else { return true }
|
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 {
|
private func isDishAssignedInCurrentWeek(dishId: UUID, context: ModelContext) -> Bool {
|
||||||
@@ -124,6 +124,6 @@ final class DishViewModel: ObservableObject {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return currentPlan.slots.contains { $0.dishId == dishId }
|
return currentPlan.slotList.contains { $0.dishId == dishId }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
.sorted { $0.weekStartDate > $1.weekStartDate }
|
.sorted { $0.weekStartDate > $1.weekStartDate }
|
||||||
.prefix(12)
|
.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(
|
let result = AutocompleteEngine.autocomplete(
|
||||||
emptySlots: emptySlots,
|
emptySlots: emptySlots,
|
||||||
currentPlan: plan,
|
currentPlan: plan,
|
||||||
@@ -176,7 +176,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
autoAssignedSlotIds = []
|
autoAssignedSlotIds = []
|
||||||
captureUndoSnapshot(plan: plan)
|
captureUndoSnapshot(plan: plan)
|
||||||
|
|
||||||
for slot in plan.slots {
|
for slot in plan.slotList {
|
||||||
if let eventId = slot.calendarEventId {
|
if let eventId = slot.calendarEventId {
|
||||||
CalendarService.shared.deleteEvent(eventId: eventId)
|
CalendarService.shared.deleteEvent(eventId: eventId)
|
||||||
slot.calendarEventId = nil
|
slot.calendarEventId = nil
|
||||||
@@ -278,13 +278,13 @@ final class HomeViewModel: ObservableObject {
|
|||||||
captureUndoSnapshot(plan: currentPlan)
|
captureUndoSnapshot(plan: currentPlan)
|
||||||
|
|
||||||
var previousByKey: [SlotKey: MealSlot] = [:]
|
var previousByKey: [SlotKey: MealSlot] = [:]
|
||||||
for slot in previousPlan.slots {
|
for slot in previousPlan.slotList {
|
||||||
previousByKey[SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType)] = slot
|
previousByKey[SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType)] = slot
|
||||||
}
|
}
|
||||||
|
|
||||||
let dishIds = Set(allDishes.map(\.id))
|
let dishIds = Set(allDishes.map(\.id))
|
||||||
var copiedCount = 0
|
var copiedCount = 0
|
||||||
for slot in currentPlan.slots {
|
for slot in currentPlan.slotList {
|
||||||
if let eventId = slot.calendarEventId {
|
if let eventId = slot.calendarEventId {
|
||||||
CalendarService.shared.deleteEvent(eventId: eventId)
|
CalendarService.shared.deleteEvent(eventId: eventId)
|
||||||
slot.calendarEventId = nil
|
slot.calendarEventId = nil
|
||||||
@@ -316,7 +316,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
guard canUndo(for: plan) else { return }
|
guard canUndo(for: plan) else { return }
|
||||||
isApplyingUndo = true
|
isApplyingUndo = true
|
||||||
|
|
||||||
for slot in plan.slots {
|
for slot in plan.slotList {
|
||||||
if let eventId = slot.calendarEventId {
|
if let eventId = slot.calendarEventId {
|
||||||
CalendarService.shared.deleteEvent(eventId: eventId)
|
CalendarService.shared.deleteEvent(eventId: eventId)
|
||||||
}
|
}
|
||||||
@@ -352,7 +352,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
private func captureUndoSnapshot(plan: WeekPlan) {
|
private func captureUndoSnapshot(plan: WeekPlan) {
|
||||||
guard !isApplyingUndo else { return }
|
guard !isApplyingUndo else { return }
|
||||||
lastWeekStartSnapshot = plan.weekStartDate
|
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))
|
(slot.id, SlotSnapshot(dishId: slot.dishId, isRuleOverridden: slot.isRuleOverridden, isEatingOut: slot.isEatingOut))
|
||||||
})
|
})
|
||||||
hasUndoSnapshot = true
|
hasUndoSnapshot = true
|
||||||
@@ -365,7 +365,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func firstFreeSlot(in plan: WeekPlan) -> MealSlot? {
|
private func firstFreeSlot(in plan: WeekPlan) -> MealSlot? {
|
||||||
plan.slots
|
plan.slotList
|
||||||
.filter { $0.dishId == nil && !$0.isEatingOut }
|
.filter { $0.dishId == nil && !$0.isEatingOut }
|
||||||
.sorted { lhs, rhs in
|
.sorted { lhs, rhs in
|
||||||
if lhs.dayOfWeek != rhs.dayOfWeek {
|
if lhs.dayOfWeek != rhs.dayOfWeek {
|
||||||
@@ -385,7 +385,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
|
|
||||||
switch settings.syncModeEnum {
|
switch settings.syncModeEnum {
|
||||||
case .weekComplete:
|
case .weekComplete:
|
||||||
if plan.slots.allSatisfy({ $0.dishId != nil || $0.isEatingOut }) {
|
if plan.slotList.allSatisfy({ $0.dishId != nil || $0.isEatingOut }) {
|
||||||
let descriptor = FetchDescriptor<Dish>()
|
let descriptor = FetchDescriptor<Dish>()
|
||||||
let dishes = (try? plan.modelContext?.fetch(descriptor)) ?? []
|
let dishes = (try? plan.modelContext?.fetch(descriptor)) ?? []
|
||||||
syncAllAssignedSlotsToCalendar(plan: plan, dishes: dishes, settings: settings)
|
syncAllAssignedSlotsToCalendar(plan: plan, dishes: dishes, settings: settings)
|
||||||
@@ -401,7 +401,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func clearCalendarEvents(for plan: WeekPlan) {
|
private func clearCalendarEvents(for plan: WeekPlan) {
|
||||||
for slot in plan.slots {
|
for slot in plan.slotList {
|
||||||
if let eventId = slot.calendarEventId {
|
if let eventId = slot.calendarEventId {
|
||||||
CalendarService.shared.deleteEvent(eventId: eventId)
|
CalendarService.shared.deleteEvent(eventId: eventId)
|
||||||
slot.calendarEventId = nil
|
slot.calendarEventId = nil
|
||||||
@@ -411,7 +411,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
|
|
||||||
private func syncAllAssignedSlotsToCalendar(plan: WeekPlan, dishes: [Dish], settings: AppSettings) {
|
private func syncAllAssignedSlotsToCalendar(plan: WeekPlan, dishes: [Dish], settings: AppSettings) {
|
||||||
let dishById = Dictionary(uniqueKeysWithValues: dishes.map { ($0.id, $0) })
|
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 {
|
guard let dishId = slot.dishId, let dish = dishById[dishId] else {
|
||||||
if let eventId = slot.calendarEventId {
|
if let eventId = slot.calendarEventId {
|
||||||
CalendarService.shared.deleteEvent(eventId: eventId)
|
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.
|
// Clean up any legacy duplicates for the same day+mealType key to keep UI mapping stable.
|
||||||
var groupedByKey: [SlotKey: [MealSlot]] = [:]
|
var groupedByKey: [SlotKey: [MealSlot]] = [:]
|
||||||
for slot in plan.slots {
|
for slot in plan.slotList {
|
||||||
let key = SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType)
|
let key = SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType)
|
||||||
groupedByKey[key, default: []].append(slot)
|
groupedByKey[key, default: []].append(slot)
|
||||||
}
|
}
|
||||||
@@ -518,7 +518,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
CalendarService.shared.deleteEvent(eventId: duplicateEventId)
|
CalendarService.shared.deleteEvent(eventId: duplicateEventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
plan.slots.removeAll { $0.id == duplicate.id }
|
plan.slotList.removeAll { $0.id == duplicate.id }
|
||||||
context.delete(duplicate)
|
context.delete(duplicate)
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
@@ -532,21 +532,21 @@ final class HomeViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var existingByKey: [SlotKey: MealSlot] = [:]
|
var existingByKey: [SlotKey: MealSlot] = [:]
|
||||||
for slot in plan.slots {
|
for slot in plan.slotList {
|
||||||
let key = SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType)
|
let key = SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType)
|
||||||
if existingByKey[key] == nil {
|
if existingByKey[key] == nil {
|
||||||
existingByKey[key] = slot
|
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))
|
!desired.contains(SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType))
|
||||||
}
|
}
|
||||||
for slot in toDelete {
|
for slot in toDelete {
|
||||||
if let eventId = slot.calendarEventId {
|
if let eventId = slot.calendarEventId {
|
||||||
CalendarService.shared.deleteEvent(eventId: eventId)
|
CalendarService.shared.deleteEvent(eventId: eventId)
|
||||||
}
|
}
|
||||||
plan.slots.removeAll { $0.id == slot.id }
|
plan.slotList.removeAll { $0.id == slot.id }
|
||||||
context.delete(slot)
|
context.delete(slot)
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
@@ -554,7 +554,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
for key in desired where existingByKey[key] == nil {
|
for key in desired where existingByKey[key] == nil {
|
||||||
let slot = MealSlot(dayOfWeek: key.dayOfWeek, mealType: key.mealType)
|
let slot = MealSlot(dayOfWeek: key.dayOfWeek, mealType: key.mealType)
|
||||||
slot.weekPlan = plan
|
slot.weekPlan = plan
|
||||||
plan.slots.append(slot)
|
plan.slotList.append(slot)
|
||||||
context.insert(slot)
|
context.insert(slot)
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ final class OnboardingViewModel: ObservableObject {
|
|||||||
let plans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
|
let plans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
|
||||||
guard let plan = plans.first(where: { $0.weekStartDate == weekStart }) else { return 0 }
|
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 }
|
guard !emptySlots.isEmpty else { return 0 }
|
||||||
|
|
||||||
let result = AutocompleteEngine.autocomplete(
|
let result = AutocompleteEngine.autocomplete(
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ final class SettingsViewModel: ObservableObject {
|
|||||||
func updateCalendarEvents(settings: AppSettings, plan: WeekPlan?) {
|
func updateCalendarEvents(settings: AppSettings, plan: WeekPlan?) {
|
||||||
guard let plan = plan, settings.syncEnabled else { return }
|
guard let plan = plan, settings.syncEnabled else { return }
|
||||||
CalendarService.shared.updateEventsTime(
|
CalendarService.shared.updateEventsTime(
|
||||||
slots: plan.slots,
|
slots: plan.slotList,
|
||||||
weekStartDate: plan.weekStartDate,
|
weekStartDate: plan.weekStartDate,
|
||||||
settings: settings
|
settings: settings
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ struct DishListView: View {
|
|||||||
|
|
||||||
for index in offsets {
|
for index in offsets {
|
||||||
let dish = dishes[index]
|
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
|
showDeleteBlockedAlert = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
if filledCount > 0 {
|
||||||
exportCallout(plan: plan, settings: settings)
|
exportCallout(plan: plan, settings: settings)
|
||||||
}
|
}
|
||||||
@@ -273,7 +273,7 @@ struct HomeView: View {
|
|||||||
weekRatingRow(plan: plan)
|
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 {
|
if viewModel.canEditCurrentWeek && !dishes.isEmpty && emptyCount > 0 {
|
||||||
autoAssignBanner(emptyCount: emptyCount, plan: plan, settings: settings)
|
autoAssignBanner(emptyCount: emptyCount, plan: plan, settings: settings)
|
||||||
}
|
}
|
||||||
@@ -410,12 +410,12 @@ struct HomeView: View {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
if let slotId = selectedEmptySlotId,
|
if let slotId = selectedEmptySlotId,
|
||||||
plan.slots.contains(where: { $0.id == slotId }) {
|
plan.slotList.contains(where: { $0.id == slotId }) {
|
||||||
SlotDishPickerSheet(
|
SlotDishPickerSheet(
|
||||||
dishes: dishes,
|
dishes: dishes,
|
||||||
tags: tags,
|
tags: tags,
|
||||||
onPickDish: { dish in
|
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
|
selectedEmptySlotId = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -440,7 +440,7 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onEatingOut: {
|
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
|
selectedEmptySlotId = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -459,12 +459,12 @@ struct HomeView: View {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
if let slotId = selectedFilledSlotId,
|
if let slotId = selectedFilledSlotId,
|
||||||
plan.slots.contains(where: { $0.id == slotId }) {
|
plan.slotList.contains(where: { $0.id == slotId }) {
|
||||||
SlotDishPickerSheet(
|
SlotDishPickerSheet(
|
||||||
dishes: dishes,
|
dishes: dishes,
|
||||||
tags: tags,
|
tags: tags,
|
||||||
onPickDish: { dish in
|
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
|
selectedFilledSlotId = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -714,7 +714,7 @@ struct HomeView: View {
|
|||||||
dishes: dishes,
|
dishes: dishes,
|
||||||
tags: tags,
|
tags: tags,
|
||||||
language: settings.languageEnum.resolved(),
|
language: settings.languageEnum.resolved(),
|
||||||
usedDishIds: Set(plan.slots.compactMap(\.dishId)),
|
usedDishIds: Set(plan.slotList.compactMap(\.dishId)),
|
||||||
usageRanking: dishUsageCounts,
|
usageRanking: dishUsageCounts,
|
||||||
onAddDish: { viewModel.showDishForm = true },
|
onAddDish: { viewModel.showDishForm = true },
|
||||||
onQuickAssignDish: { dish in
|
onQuickAssignDish: { dish in
|
||||||
@@ -730,7 +730,7 @@ struct HomeView: View {
|
|||||||
editingDish = dish
|
editingDish = dish
|
||||||
},
|
},
|
||||||
onDeleteDish: { dish in
|
onDeleteDish: { dish in
|
||||||
let isAssignedInCurrentWeek = plan.slots.contains { $0.dishId == dish.id }
|
let isAssignedInCurrentWeek = plan.slotList.contains { $0.dishId == dish.id }
|
||||||
if isAssignedInCurrentWeek {
|
if isAssignedInCurrentWeek {
|
||||||
viewModel.toastMessage = localizedString("dish_delete_blocked_message", language: settings.languageEnum.resolved())
|
viewModel.toastMessage = localizedString("dish_delete_blocked_message", language: settings.languageEnum.resolved())
|
||||||
viewModel.showToast = true
|
viewModel.showToast = true
|
||||||
@@ -775,13 +775,13 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func isWeekComplete(plan: WeekPlan) -> Bool {
|
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] {
|
private var dishUsageCounts: [UUID: Int] {
|
||||||
var counts: [UUID: Int] = [:]
|
var counts: [UUID: Int] = [:]
|
||||||
for plan in weekPlans {
|
for plan in weekPlans {
|
||||||
for slot in plan.slots {
|
for slot in plan.slotList {
|
||||||
if let dishId = slot.dishId {
|
if let dishId = slot.dishId {
|
||||||
counts[dishId, default: 0] += 1
|
counts[dishId, default: 0] += 1
|
||||||
}
|
}
|
||||||
@@ -910,14 +910,14 @@ struct HomeView: View {
|
|||||||
|
|
||||||
private func previousWeekHasMenu() -> Bool {
|
private func previousWeekHasMenu() -> Bool {
|
||||||
guard let previous = fetchWeekPlan(for: viewModel.currentWeekStart.addingDays(-7)) else { return false }
|
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
|
/// Copies the previous week, asking to confirm first only when the current
|
||||||
/// week already has dishes that would be overwritten.
|
/// week already has dishes that would be overwritten.
|
||||||
private func requestCopyPreviousWeek(plan: WeekPlan, settings: AppSettings, source: String) {
|
private func requestCopyPreviousWeek(plan: WeekPlan, settings: AppSettings, source: String) {
|
||||||
copyPreviousSource = source
|
copyPreviousSource = source
|
||||||
if plan.slots.contains(where: { $0.dishId != nil }) {
|
if plan.slotList.contains(where: { $0.dishId != nil }) {
|
||||||
showCopyPreviousConfirm = true
|
showCopyPreviousConfirm = true
|
||||||
} else {
|
} else {
|
||||||
copyFromPreviousWeek(currentPlan: plan, settings: settings)
|
copyFromPreviousWeek(currentPlan: plan, settings: settings)
|
||||||
@@ -994,10 +994,10 @@ struct HomeView: View {
|
|||||||
|
|
||||||
private func preferredWeekPlan(from plans: [WeekPlan]) -> WeekPlan? {
|
private func preferredWeekPlan(from plans: [WeekPlan]) -> WeekPlan? {
|
||||||
plans.max { lhs, rhs in
|
plans.max { lhs, rhs in
|
||||||
let lhsAssigned = lhs.slots.filter { $0.dishId != nil }.count
|
let lhsAssigned = lhs.slotList.filter { $0.dishId != nil }.count
|
||||||
let rhsAssigned = rhs.slots.filter { $0.dishId != nil }.count
|
let rhsAssigned = rhs.slotList.filter { $0.dishId != nil }.count
|
||||||
if lhsAssigned != rhsAssigned { return lhsAssigned < rhsAssigned }
|
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
|
return lhs.updatedAt < rhs.updatedAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1078,7 +1078,7 @@ struct HomeView: View {
|
|||||||
private func evaluateReviewPrompt() {
|
private func evaluateReviewPrompt() {
|
||||||
let descriptor = FetchDescriptor<WeekPlan>()
|
let descriptor = FetchDescriptor<WeekPlan>()
|
||||||
guard let plans = try? context.fetch(descriptor) else { return }
|
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 }
|
guard ReviewPromptService.shared.shouldShowFunnelAfterWeekCompletion(completedWeeks: completedWeeks) else { return }
|
||||||
ReviewPromptService.shared.markFunnelShown(completedWeeks: completedWeeks)
|
ReviewPromptService.shared.markFunnelShown(completedWeeks: completedWeeks)
|
||||||
showReviewSentimentPrompt = true
|
showReviewSentimentPrompt = true
|
||||||
@@ -1092,7 +1092,7 @@ struct HomeView: View {
|
|||||||
// instead of being asked. Reuses the home's autoComplete choreography.
|
// instead of being asked. Reuses the home's autoComplete choreography.
|
||||||
if UserDefaults.standard.bool(forKey: OnboardingViewModel.pendingAutoFillOnLaunchKey) {
|
if UserDefaults.standard.bool(forKey: OnboardingViewModel.pendingAutoFillOnLaunchKey) {
|
||||||
UserDefaults.standard.set(false, 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)
|
viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings, allPlans: weekPlans)
|
||||||
}
|
}
|
||||||
schedulePostOnboardingPremiumPromptIfNeeded(settings: settings)
|
schedulePostOnboardingPremiumPromptIfNeeded(settings: settings)
|
||||||
@@ -1100,7 +1100,7 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let shouldAskAutoAssign = UserDefaults.standard.bool(forKey: OnboardingViewModel.pendingAutoAssignPromptKey)
|
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
|
showPostOnboardingAutoAssignPrompt = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1162,7 +1162,7 @@ private struct RuleViolationsPanelSheet: View {
|
|||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
ForEach(violations, id: \.slotId) { violation in
|
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(
|
ViolationRow(
|
||||||
violation: violation,
|
violation: violation,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
@@ -1496,7 +1496,7 @@ private struct MonthlyHistoryView: View {
|
|||||||
Text(weekLabel(for: plan.weekStartDate))
|
Text(weekLabel(for: plan.weekStartDate))
|
||||||
.font(.mealMoodBodyBold)
|
.font(.mealMoodBodyBold)
|
||||||
.foregroundColor(.mealMoodTextPrimary)
|
.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)
|
.font(.mealMoodCaption)
|
||||||
.foregroundColor(.mealMoodTextSecondary)
|
.foregroundColor(.mealMoodTextSecondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ struct WeekCalendarView: View {
|
|||||||
if let cached = displaySlotsByKey[key] {
|
if let cached = displaySlotsByKey[key] {
|
||||||
return cached
|
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 }
|
guard !matching.isEmpty else { return nil }
|
||||||
let preferred = MealSlot.preferredForDuplicateResolution(matching)
|
let preferred = MealSlot.preferredForDuplicateResolution(matching)
|
||||||
return DisplaySlot(
|
return DisplaySlot(
|
||||||
@@ -246,7 +246,7 @@ struct WeekCalendarView: View {
|
|||||||
viewModel.confirmInvalidDrop(dish, to: targetSlot, plan: plan, settings: settings)
|
viewModel.confirmInvalidDrop(dish, to: targetSlot, plan: plan, settings: settings)
|
||||||
return true
|
return true
|
||||||
case .slot(let sourceSlotId):
|
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(
|
viewModel.moveOrSwapDish(
|
||||||
from: sourceSlot,
|
from: sourceSlot,
|
||||||
to: targetSlot,
|
to: targetSlot,
|
||||||
@@ -358,7 +358,7 @@ struct WeekCalendarView: View {
|
|||||||
|
|
||||||
private func refreshDisplaySlots() {
|
private func refreshDisplaySlots() {
|
||||||
var resolved: [String: DisplaySlot] = [:]
|
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 {
|
for (key, candidates) in grouped {
|
||||||
guard !candidates.isEmpty else { continue }
|
guard !candidates.isEmpty else { continue }
|
||||||
let preferred = MealSlot.preferredForDuplicateResolution(candidates)
|
let preferred = MealSlot.preferredForDuplicateResolution(candidates)
|
||||||
@@ -387,10 +387,10 @@ struct WeekCalendarView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func liveSlot(for display: DisplaySlot) -> MealSlot? {
|
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
|
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 }
|
guard !matching.isEmpty else { return nil }
|
||||||
return MealSlot.preferredForDuplicateResolution(matching)
|
return MealSlot.preferredForDuplicateResolution(matching)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -507,7 +507,7 @@ struct WeekPlanShareView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func dishName(day: Int, mealType: MealType) -> String {
|
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") }
|
guard let slot else { return String(localized: "share_slot_empty") }
|
||||||
if slot.isEatingOut { return String(localized: "slot_eating_out") }
|
if slot.isEatingOut { return String(localized: "slot_eating_out") }
|
||||||
guard let dishId = slot.dishId, let dish = dishes.first(where: { $0.id == dishId }) else {
|
guard let dishId = slot.dishId, let dish = dishes.first(where: { $0.id == dishId }) else {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ struct ShoppingListView: View {
|
|||||||
private var plannedDishes: [Dish] {
|
private var plannedDishes: [Dish] {
|
||||||
var seen = Set<UUID>()
|
var seen = Set<UUID>()
|
||||||
var result: [Dish] = []
|
var result: [Dish] = []
|
||||||
let sortedSlots = plan.slots.sorted {
|
let sortedSlots = plan.slotList.sorted {
|
||||||
($0.dayOfWeek, $0.mealType) < ($1.dayOfWeek, $1.mealType)
|
($0.dayOfWeek, $0.mealType) < ($1.dayOfWeek, $1.mealType)
|
||||||
}
|
}
|
||||||
for slot in sortedSlots {
|
for slot in sortedSlots {
|
||||||
|
|||||||
@@ -130,14 +130,14 @@ struct StatsView: View {
|
|||||||
// MARK: - Computed stats
|
// MARK: - Computed stats
|
||||||
|
|
||||||
private var plannedWeeks: [WeekPlan] {
|
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 weeksWithAnyDish: Int { plannedWeeks.count }
|
||||||
|
|
||||||
private var completeWeeks: Int {
|
private var completeWeeks: Int {
|
||||||
weekPlans.filter { plan in
|
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
|
}.count
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +170,7 @@ struct StatsView: View {
|
|||||||
private var dishUsage: [UUID: Int] {
|
private var dishUsage: [UUID: Int] {
|
||||||
var counts: [UUID: Int] = [:]
|
var counts: [UUID: Int] = [:]
|
||||||
for plan in weekPlans {
|
for plan in weekPlans {
|
||||||
for slot in plan.slots {
|
for slot in plan.slotList {
|
||||||
if let id = slot.dishId { counts[id, default: 0] += 1 }
|
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) })
|
let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) })
|
||||||
var counts: [UUID: Int] = [:]
|
var counts: [UUID: Int] = [:]
|
||||||
for plan in weekPlans {
|
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 }
|
guard let dishId = slot.dishId, let dish = dishMap[dishId] else { continue }
|
||||||
for tagId in dish.tagIds { counts[tagId, default: 0] += 1 }
|
for tagId in dish.tagIds { counts[tagId, default: 0] += 1 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ final class AutocompleteEngineTests: XCTestCase {
|
|||||||
let plan = WeekPlan(weekStartDate: Date().startOfWeek())
|
let plan = WeekPlan(weekStartDate: Date().startOfWeek())
|
||||||
let mondayDinner = MealSlot(dayOfWeek: 0, mealType: MealType.dinner.rawValue, dishId: dishA.id)
|
let mondayDinner = MealSlot(dayOfWeek: 0, mealType: MealType.dinner.rawValue, dishId: dishA.id)
|
||||||
let tuesdayDinner = MealSlot(dayOfWeek: 1, mealType: MealType.dinner.rawValue, dishId: nil)
|
let tuesdayDinner = MealSlot(dayOfWeek: 1, mealType: MealType.dinner.rawValue, dishId: nil)
|
||||||
plan.slots = [mondayDinner, tuesdayDinner]
|
plan.slotList = [mondayDinner, tuesdayDinner]
|
||||||
|
|
||||||
let isValid = AutocompleteEngine.validateDrop(
|
let isValid = AutocompleteEngine.validateDrop(
|
||||||
dish: dishB,
|
dish: dishB,
|
||||||
@@ -47,7 +47,7 @@ final class AutocompleteEngineTests: XCTestCase {
|
|||||||
|
|
||||||
let mondayDinner = MealSlot(dayOfWeek: 0, mealType: MealType.dinner.rawValue, dishId: fishDish.id)
|
let mondayDinner = MealSlot(dayOfWeek: 0, mealType: MealType.dinner.rawValue, dishId: fishDish.id)
|
||||||
let tuesdayDinner = MealSlot(dayOfWeek: 1, mealType: MealType.dinner.rawValue, dishId: nil)
|
let tuesdayDinner = MealSlot(dayOfWeek: 1, mealType: MealType.dinner.rawValue, dishId: nil)
|
||||||
plan.slots = [mondayDinner, tuesdayDinner]
|
plan.slotList = [mondayDinner, tuesdayDinner]
|
||||||
|
|
||||||
let result = AutocompleteEngine.autocomplete(
|
let result = AutocompleteEngine.autocomplete(
|
||||||
emptySlots: [tuesdayDinner],
|
emptySlots: [tuesdayDinner],
|
||||||
|
|||||||
Reference in New Issue
Block a user