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
+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
)