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