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:
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user