24a6743ed2
Un plato aparecia en la lista de incumplimientos sin explicacion (captura del usuario: "Ensalada campera"): findViolations listaba los slots con isRuleOverridden, una marca puesta al asignar que quedaba obsoleta cuando el conflicto ya se habia resuelto. Y al reves, editar una regla despues de planificar no detectaba nada. - findViolations recorre el plan y reporta solo lo que incumple AHORA, siempre con sus razones (nunca una entrada vacia) - MealSlot.isRuleIgnored: "Ignorar" pasa a ser persistente, porque si no el aviso recalculado volveria enseguida; se resetea al cambiar o quitar el plato del hueco - El triangulo del calendario usa tambien el estado real - Campo en el payload KV (opcional, retrocompatible) y en el esquema CloudKit de Development - 3 tests: marca obsoleta no se lista, regla endurecida despues se detecta, y lo ignorado no reaparece Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
308 lines
12 KiB
Swift
308 lines
12 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,
|
|
isEatingOut: $0.isEatingOut,
|
|
isSkipped: $0.isSkipped,
|
|
isRuleIgnored: $0.isRuleIgnored
|
|
)
|
|
}
|
|
)
|
|
}
|
|
)
|
|
|
|
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,
|
|
isEatingOut: slotPayload.isEatingOut ?? false,
|
|
isSkipped: slotPayload.isSkipped ?? false,
|
|
isRuleIgnored: slotPayload.isRuleIgnored ?? false
|
|
)
|
|
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?
|
|
// Optionals so payloads written by older installs (no key) still decode.
|
|
var secondaryDishId: UUID?
|
|
let calendarEventId: String?
|
|
let isRuleOverridden: Bool
|
|
var isEatingOut: Bool?
|
|
var isSkipped: Bool?
|
|
var isRuleIgnored: Bool?
|
|
}
|