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