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:
alexandrev-tibco
2026-07-12 18:13:45 +02:00
parent e15ff93465
commit 4ffa15b06a
28 changed files with 238 additions and 98 deletions
+5
View File
@@ -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)
}
+22
View File
@@ -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])
+7 -6
View File
@@ -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?
+5 -4
View File
@@ -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").
+4 -3
View File
@@ -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
+5 -4
View File
@@ -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(),
+6 -5
View File
@@ -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(),
+14 -5
View File
@@ -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(),
+4
View File
@@ -16,6 +16,10 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.0</string>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
+4
View File
@@ -6,6 +6,10 @@
<array>
<string>iCloud.com.alexandrev.mealmood</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
<key>com.apple.security.application-groups</key>
+10 -10
View File
@@ -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
@@ -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)
}
}
}
}
+1 -1
View File
@@ -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)
}
}
+6 -2
View File
@@ -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)
}
}
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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 }
+2 -2
View File
@@ -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 }
}
}
+16 -16
View File
@@ -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<Dish>()
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
}
@@ -191,7 +191,7 @@ final class OnboardingViewModel: ObservableObject {
let plans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
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(
+1 -1
View File
@@ -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
)
+1 -1
View File
@@ -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
}
+21 -21
View File
@@ -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<WeekPlan>()
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)
}
+5 -5
View File
@@ -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)
}
+1 -1
View File
@@ -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 {
@@ -29,7 +29,7 @@ struct ShoppingListView: View {
private var plannedDishes: [Dish] {
var seen = Set<UUID>()
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 {
+4 -4
View File
@@ -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 }
}