Files
FamilyMealPlanner/MealMood/Services/ICloudSyncService.swift
T
alexandrev-tibco a0f9fa63d3 platos: segundo plato por comida (ensalada + salmon)
- MealSlot.secondaryDishId (opcional, aditivo CloudKit); se limpia al
  sustituir/quitar/vaciar/marcar comer fuera y viaja en swaps, copia de
  semana anterior, undo y snapshot KV de iCloud (retrocompatible)
- Picker de hueco lleno: toggle "anadir como segundo plato" + boton para
  quitarlo; HomeViewModel.assignSecondaryDish/removeSecondaryDish
- Se muestra "Plato + Segundo" en calendario, widget y export; la lista
  de la compra incluye los ingredientes del segundo; stats lo cuentan
- Evento analytics secondary_dish_added; strings en 6 idiomas

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
2026-09-01 11:37:23 +02:00

299 lines
11 KiB
Swift

import Foundation
import SwiftData
@MainActor
final class ICloudSyncService {
static let shared = ICloudSyncService()
private let store = NSUbiquitousKeyValueStore.default
private let payloadKey = "mealmood_sync_payload_v1"
private let lastAppliedKey = "mealmood_sync_last_applied"
private var isApplyingRemote = false
private init() {}
func pullRemoteIfNeeded(context: ModelContext) async {
// 2.0: CloudKit syncs the store directly applying legacy KV snapshots
// on top would duplicate/fight with it. KV stays for 1.x devices only.
guard !CloudSyncRuntime.isCloudKitActive else { return }
store.synchronize()
guard let data = store.data(forKey: payloadKey),
let snapshot = try? JSONDecoder().decode(SyncSnapshot.self, from: data) else {
return
}
let lastApplied = UserDefaults.standard.double(forKey: lastAppliedKey)
let remoteTs = snapshot.updatedAt.timeIntervalSince1970
if remoteTs <= lastApplied {
return
}
isApplyingRemote = true
defer { isApplyingRemote = false }
apply(snapshot: snapshot, context: context)
UserDefaults.standard.set(remoteTs, forKey: lastAppliedKey)
}
func pushLocalSnapshot(context: ModelContext) async {
guard !CloudSyncRuntime.isCloudKitActive else { return }
if isApplyingRemote { return }
guard let snapshot = makeSnapshot(context: context) else { return }
guard let data = try? JSONEncoder().encode(snapshot) else { return }
store.set(data, forKey: payloadKey)
store.synchronize()
UserDefaults.standard.set(snapshot.updatedAt.timeIntervalSince1970, forKey: lastAppliedKey)
}
private func makeSnapshot(context: ModelContext) -> SyncSnapshot? {
let settings = (try? context.fetch(FetchDescriptor<AppSettings>()))?.first
let tags = (try? context.fetch(FetchDescriptor<Tag>())) ?? []
let dishes = (try? context.fetch(FetchDescriptor<Dish>())) ?? []
let plans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
guard settings != nil || !tags.isEmpty || !dishes.isEmpty || !plans.isEmpty else {
return nil
}
let snapshot = SyncSnapshot(
updatedAt: Date(),
settings: settings.map {
SettingsPayload(
mealWindows: $0.mealWindows,
enabledMealTypesRaw: $0.enabledMealTypesRaw,
includeWeekends: $0.includeWeekends,
language: $0.language,
calendarId: $0.calendarId,
syncEnabled: $0.syncEnabled,
syncMode: $0.syncMode,
lunchTime: $0.lunchTime,
dinnerTime: $0.dinnerTime,
eventDuration: $0.eventDuration,
eventPrefix: $0.eventPrefix,
reminderMinutesBefore: $0.reminderMinutesBefore,
iCloudSyncEnabled: $0.iCloudSyncEnabled,
weekExportStyle: $0.weekExportStyle,
onboardingCompleted: $0.onboardingCompleted
)
},
tags: tags.map {
TagPayload(
id: $0.id,
name: $0.name,
nameEN: $0.nameEN,
color: $0.color,
maxPerWeek: $0.maxPerWeek,
noConsecutive: $0.noConsecutive,
noDuplicateInDay: $0.noDuplicateInDay,
mealTypeRestriction: $0.mealTypeRestriction,
isDefault: $0.isDefault,
sortOrder: $0.sortOrder
)
},
dishes: dishes.map {
DishPayload(
id: $0.id,
name: $0.name,
descriptionText: $0.descriptionText,
tagIds: $0.tagIds,
createdAt: $0.createdAt,
ingredients: $0.ingredients
)
},
weekPlans: plans.map { plan in
WeekPlanPayload(
id: plan.id,
weekStartDate: plan.weekStartDate,
createdAt: plan.createdAt,
updatedAt: plan.updatedAt,
slots: plan.slotList.map {
MealSlotPayload(
id: $0.id,
dayOfWeek: $0.dayOfWeek,
mealType: $0.mealType,
dishId: $0.dishId,
secondaryDishId: $0.secondaryDishId,
calendarEventId: $0.calendarEventId,
isRuleOverridden: $0.isRuleOverridden
)
}
)
}
)
return snapshot
}
private func apply(snapshot: SyncSnapshot, context: ModelContext) {
guard snapshot.settings != nil || !snapshot.tags.isEmpty || !snapshot.dishes.isEmpty || !snapshot.weekPlans.isEmpty else {
return
}
let existingSlots = (try? context.fetch(FetchDescriptor<MealSlot>())) ?? []
let existingPlans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
let existingDishes = (try? context.fetch(FetchDescriptor<Dish>())) ?? []
let existingTags = (try? context.fetch(FetchDescriptor<Tag>())) ?? []
let existingSettings = (try? context.fetch(FetchDescriptor<AppSettings>())) ?? []
existingSlots.forEach { context.delete($0) }
existingPlans.forEach { context.delete($0) }
existingDishes.forEach { context.delete($0) }
existingTags.forEach { context.delete($0) }
existingSettings.forEach { context.delete($0) }
if let settingsPayload = snapshot.settings {
let settings = AppSettings()
settings.mealWindows = settingsPayload.mealWindows
settings.enabledMealTypesRaw = settingsPayload.enabledMealTypesRaw
settings.includeWeekends = settingsPayload.includeWeekends
settings.language = settingsPayload.language
settings.calendarId = settingsPayload.calendarId
settings.syncEnabled = settingsPayload.syncEnabled
settings.syncMode = settingsPayload.syncMode
settings.lunchTime = settingsPayload.lunchTime
settings.dinnerTime = settingsPayload.dinnerTime
settings.eventDuration = settingsPayload.eventDuration
settings.eventPrefix = settingsPayload.eventPrefix
settings.reminderMinutesBefore = settingsPayload.reminderMinutesBefore
settings.iCloudSyncEnabled = settingsPayload.iCloudSyncEnabled
settings.weekExportStyle = settingsPayload.weekExportStyle
// isPremium is intentionally NOT synced via iCloud.
// It is determined exclusively by StoreKit to avoid stale state
// overwriting a freshly completed purchase.
settings.onboardingCompleted = settingsPayload.onboardingCompleted
context.insert(settings)
}
snapshot.tags.forEach { payload in
let tag = Tag(
id: payload.id,
name: payload.name,
nameEN: payload.nameEN,
color: payload.color,
maxPerWeek: payload.maxPerWeek,
noConsecutive: payload.noConsecutive,
noDuplicateInDay: payload.noDuplicateInDay,
mealTypeRestriction: payload.mealTypeRestriction,
isDefault: payload.isDefault,
sortOrder: payload.sortOrder
)
context.insert(tag)
}
snapshot.dishes.forEach { payload in
let dish = Dish(
id: payload.id,
name: payload.name,
descriptionText: payload.descriptionText,
tagIds: payload.tagIds,
createdAt: payload.createdAt,
ingredients: payload.ingredients ?? []
)
context.insert(dish)
}
snapshot.weekPlans.forEach { payload in
let plan = WeekPlan(
id: payload.id,
weekStartDate: payload.weekStartDate,
slots: [],
createdAt: payload.createdAt,
updatedAt: payload.updatedAt
)
context.insert(plan)
payload.slots.forEach { slotPayload in
let slot = MealSlot(
id: slotPayload.id,
dayOfWeek: slotPayload.dayOfWeek,
mealType: slotPayload.mealType,
dishId: slotPayload.dishId,
secondaryDishId: slotPayload.secondaryDishId,
calendarEventId: slotPayload.calendarEventId,
isRuleOverridden: slotPayload.isRuleOverridden
)
slot.weekPlan = plan
plan.slotList.append(slot)
context.insert(slot)
}
}
try? context.save()
}
}
private struct SyncSnapshot: Codable {
let updatedAt: Date
let settings: SettingsPayload?
let tags: [TagPayload]
let dishes: [DishPayload]
let weekPlans: [WeekPlanPayload]
}
private struct SettingsPayload: Codable {
let mealWindows: String
// Optional for backward-compatibility with pre-2.0 snapshots.
let enabledMealTypesRaw: String?
let includeWeekends: Bool
let language: String
let calendarId: String?
let syncEnabled: Bool
let syncMode: String?
let lunchTime: Date
let dinnerTime: Date
let eventDuration: Int
let eventPrefix: String
let reminderMinutesBefore: Int?
let iCloudSyncEnabled: Bool?
let weekExportStyle: String?
let onboardingCompleted: Bool
// isPremium is intentionally omitted determined by StoreKit only, never by sync.
// Old payloads in iCloud may contain this key; it is safely ignored by the decoder.
}
private struct TagPayload: Codable {
let id: UUID
let name: String
let nameEN: String
let color: String
let maxPerWeek: Int?
let noConsecutive: Bool
let noDuplicateInDay: Bool
let mealTypeRestriction: String?
let isDefault: Bool
let sortOrder: Int
}
private struct DishPayload: Codable {
let id: UUID
let name: String
let descriptionText: String?
let tagIds: [UUID]
let createdAt: Date
// Optional for backward-compatibility with pre-2.0 snapshots. Photos are not
// synced through the KV store (size limits) they ride CloudKit instead.
let ingredients: [String]?
}
private struct WeekPlanPayload: Codable {
let id: UUID
let weekStartDate: Date
let createdAt: Date
let updatedAt: Date
let slots: [MealSlotPayload]
}
private struct MealSlotPayload: Codable {
let id: UUID
let dayOfWeek: Int
let mealType: String
let dishId: UUID?
// Optional so payloads written by older installs (no key) still decode.
var secondaryDishId: UUID?
let calendarEventId: String?
let isRuleOverridden: Bool
}