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