163fd6026a
CloudKit sincroniza la base privada de un Apple ID: ni llega a Android ni deja que dos cuentas editen el mismo plan (CKShare sigue sin existir en SwiftData). El contenido de un hogar pasa por tanto a Firestore, y un dispositivo que entra en un hogar construye el store local sin CloudKit — dos espejos escribiendo los mismos objetos se pelean, que es justo lo que ya obligó a apagar el sync por iCloud KV. SwiftData sigue siendo el store local y el modo offline; HouseholdSyncService es lo unico que habla con la red. Detecta cambios comparando una huella del contenido de cada documento con la ultima sincronizada (el "shadow"), asi que no hace falta instrumentar con updatedAt las treinta vistas que mutan modelos. Los borrados van como tombstone: un borrado duro volveria desde cualquier miembro que estuviera sin conexion. Semanas y slots usan id derivado del contenido (2026-09-14, 5-dinner) para que dos miembros que abren la misma semana escriban el mismo documento en vez de crear dos, y para que los conflictos se resuelvan por slot y no por semana. Incluye reglas de seguridad (solo miembros; los codigos de invitacion se pueden leer por id pero no listar), pantalla de hogar en Ajustes con Sign in with Apple, invitacion por codigo de 6 caracteres sin vocales ni 0/O/1/I, y la eleccion al unirse entre llevarse los platos propios o adoptar los del hogar. Fuera de esta fase: fotos de platos (necesitan Storage) y el cliente Android. Refs #33 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246
479 lines
19 KiB
Swift
479 lines
19 KiB
Swift
import Foundation
|
|
import SwiftData
|
|
import FirebaseFirestore
|
|
|
|
/// Mirrors the household's content between the local SwiftData store and
|
|
/// Firestore. The UI keeps reading SwiftData through `@Query`; this service is
|
|
/// the only thing that talks to the network.
|
|
///
|
|
/// How changes are detected without touching every mutation site in the app:
|
|
/// each synced document carries a fingerprint of its content, and the last
|
|
/// fingerprint pushed or received per document (the "shadow") is persisted.
|
|
/// Local content whose fingerprint no longer matches the shadow is a local
|
|
/// edit; an id present in the shadow but gone locally is a local delete, which
|
|
/// becomes a `deletedAt` tombstone (a hard delete would come back from any
|
|
/// member who was offline when it happened).
|
|
///
|
|
/// Conflicts resolve last-write-wins per document. Documents are small and
|
|
/// granular — one per meal slot — so two people filling different days of the
|
|
/// same week never collide.
|
|
@MainActor
|
|
final class HouseholdSyncService: ObservableObject {
|
|
static let shared = HouseholdSyncService()
|
|
|
|
@Published private(set) var isSyncing = false
|
|
@Published private(set) var lastSyncedAt: Date?
|
|
@Published private(set) var lastError: String?
|
|
|
|
private let db = Firestore.firestore()
|
|
private var context: ModelContext?
|
|
private var householdId: String?
|
|
|
|
private var contentListeners: [ListenerRegistration] = []
|
|
private var slotListeners: [String: ListenerRegistration] = [:]
|
|
private var focusedWeeks: [String] = []
|
|
private static let maxFocusedWeeks = 6
|
|
|
|
private var shadow = SyncShadow()
|
|
private var pushTask: Task<Void, Never>?
|
|
private var isApplyingRemote = false
|
|
private var saveObserver: NSObjectProtocol?
|
|
|
|
private init() {}
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
func start(context: ModelContext) {
|
|
guard let householdId = HouseholdRuntime.householdId,
|
|
AuthService.shared.isSignedIn,
|
|
HouseholdRuntime.isHouseholdStore else { return }
|
|
guard self.householdId != householdId else { return }
|
|
|
|
self.context = context
|
|
self.householdId = householdId
|
|
shadow.load(householdId: householdId)
|
|
|
|
observeContent(householdId: householdId)
|
|
observeLocalSaves()
|
|
schedulePush()
|
|
}
|
|
|
|
func stop() {
|
|
contentListeners.forEach { $0.remove() }
|
|
contentListeners = []
|
|
slotListeners.values.forEach { $0.remove() }
|
|
slotListeners = [:]
|
|
focusedWeeks = []
|
|
if let saveObserver {
|
|
NotificationCenter.default.removeObserver(saveObserver)
|
|
}
|
|
saveObserver = nil
|
|
pushTask?.cancel()
|
|
householdId = nil
|
|
context = nil
|
|
}
|
|
|
|
/// Keeps a listener on the weeks the user is actually looking at. Watching
|
|
/// every week ever planned would mean a listener per week, forever.
|
|
func focus(weekStart: Date) {
|
|
guard let householdId else { return }
|
|
let key = HouseholdDocuments.weekKey(for: weekStart)
|
|
guard slotListeners[key] == nil else { return }
|
|
|
|
let listener = db.collection("households").document(householdId)
|
|
.collection("weekPlans").document(key).collection("slots")
|
|
.addSnapshotListener { [weak self] snapshot, error in
|
|
guard let snapshot else {
|
|
Task { @MainActor in self?.lastError = error?.localizedDescription }
|
|
return
|
|
}
|
|
Task { @MainActor in
|
|
self?.applyRemoteSlots(snapshot, weekKey: key)
|
|
}
|
|
}
|
|
|
|
slotListeners[key] = listener
|
|
focusedWeeks.append(key)
|
|
while focusedWeeks.count > Self.maxFocusedWeeks {
|
|
let dropped = focusedWeeks.removeFirst()
|
|
slotListeners.removeValue(forKey: dropped)?.remove()
|
|
}
|
|
}
|
|
|
|
// MARK: - Remote → local
|
|
|
|
private func observeContent(householdId: String) {
|
|
let household = db.collection("households").document(householdId)
|
|
|
|
contentListeners.append(
|
|
household.collection("dishes").addSnapshotListener { [weak self] snapshot, _ in
|
|
guard let snapshot else { return }
|
|
Task { @MainActor in self?.applyRemoteDishes(snapshot) }
|
|
}
|
|
)
|
|
contentListeners.append(
|
|
household.collection("tags").addSnapshotListener { [weak self] snapshot, _ in
|
|
guard let snapshot else { return }
|
|
Task { @MainActor in self?.applyRemoteTags(snapshot) }
|
|
}
|
|
)
|
|
contentListeners.append(
|
|
household.collection("weekPlans").addSnapshotListener { [weak self] snapshot, _ in
|
|
guard let snapshot else { return }
|
|
Task { @MainActor in self?.applyRemoteWeekPlans(snapshot) }
|
|
}
|
|
)
|
|
contentListeners.append(
|
|
household.collection("shoppingItems").addSnapshotListener { [weak self] snapshot, _ in
|
|
guard let snapshot else { return }
|
|
Task { @MainActor in self?.applyRemoteShoppingItems(snapshot) }
|
|
}
|
|
)
|
|
}
|
|
|
|
private func applyRemoteDishes(_ snapshot: QuerySnapshot) {
|
|
guard let context else { return }
|
|
withRemoteApplication {
|
|
let existing = (try? context.fetch(FetchDescriptor<Dish>())) ?? []
|
|
var byId = Dictionary(existing.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
|
|
|
|
for document in snapshot.documents where !document.metadata.hasPendingWrites {
|
|
guard let id = UUID(uuidString: document.documentID) else { continue }
|
|
let data = document.data()
|
|
|
|
if data["deletedAt"] != nil {
|
|
if let dish = byId[id] { context.delete(dish) }
|
|
shadow.remove(path: "dishes/\(document.documentID)")
|
|
continue
|
|
}
|
|
|
|
let dish = byId[id] ?? {
|
|
let created = Dish(id: id, name: data["name"] as? String ?? "")
|
|
context.insert(created)
|
|
byId[id] = created
|
|
return created
|
|
}()
|
|
HouseholdDocuments.apply(data, to: dish)
|
|
shadow.set(path: "dishes/\(document.documentID)",
|
|
fingerprint: HouseholdDocuments.fingerprint(HouseholdDocuments.fields(for: dish)))
|
|
}
|
|
try? context.save()
|
|
}
|
|
}
|
|
|
|
private func applyRemoteTags(_ snapshot: QuerySnapshot) {
|
|
guard let context else { return }
|
|
withRemoteApplication {
|
|
let existing = (try? context.fetch(FetchDescriptor<Tag>())) ?? []
|
|
var byId = Dictionary(existing.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
|
|
|
|
for document in snapshot.documents where !document.metadata.hasPendingWrites {
|
|
guard let id = UUID(uuidString: document.documentID) else { continue }
|
|
let data = document.data()
|
|
|
|
if data["deletedAt"] != nil {
|
|
if let tag = byId[id] { context.delete(tag) }
|
|
shadow.remove(path: "tags/\(document.documentID)")
|
|
continue
|
|
}
|
|
|
|
let tag = byId[id] ?? {
|
|
let created = Tag(id: id, name: data["name"] as? String ?? "", color: data["color"] as? String ?? "#FF8A65")
|
|
context.insert(created)
|
|
byId[id] = created
|
|
return created
|
|
}()
|
|
HouseholdDocuments.apply(data, to: tag)
|
|
shadow.set(path: "tags/\(document.documentID)",
|
|
fingerprint: HouseholdDocuments.fingerprint(HouseholdDocuments.fields(for: tag)))
|
|
}
|
|
try? context.save()
|
|
}
|
|
}
|
|
|
|
private func applyRemoteWeekPlans(_ snapshot: QuerySnapshot) {
|
|
guard let context else { return }
|
|
withRemoteApplication {
|
|
let existing = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
|
|
var byKey = Dictionary(
|
|
existing.map { (HouseholdDocuments.weekKey(for: $0.weekStartDate), $0) },
|
|
uniquingKeysWith: { first, _ in first }
|
|
)
|
|
|
|
for document in snapshot.documents where !document.metadata.hasPendingWrites {
|
|
let data = document.data()
|
|
let key = document.documentID
|
|
|
|
if data["deletedAt"] != nil {
|
|
if let plan = byKey[key] { context.delete(plan) }
|
|
shadow.remove(path: "weekPlans/\(key)")
|
|
continue
|
|
}
|
|
|
|
guard let weekStart = (data["weekStartDate"] as? Double).map({ Date(timeIntervalSince1970: $0) }) else {
|
|
continue
|
|
}
|
|
let plan = byKey[key] ?? {
|
|
let created = WeekPlan(weekStartDate: weekStart)
|
|
context.insert(created)
|
|
byKey[key] = created
|
|
return created
|
|
}()
|
|
HouseholdDocuments.apply(data, to: plan)
|
|
shadow.set(path: "weekPlans/\(key)",
|
|
fingerprint: HouseholdDocuments.fingerprint(HouseholdDocuments.fields(for: plan)))
|
|
}
|
|
try? context.save()
|
|
}
|
|
}
|
|
|
|
private func applyRemoteSlots(_ snapshot: QuerySnapshot, weekKey: String) {
|
|
guard let context else { return }
|
|
withRemoteApplication {
|
|
let plans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
|
|
guard let plan = plans.first(where: { HouseholdDocuments.weekKey(for: $0.weekStartDate) == weekKey }) else {
|
|
return
|
|
}
|
|
|
|
for document in snapshot.documents where !document.metadata.hasPendingWrites {
|
|
let data = document.data()
|
|
guard let dayOfWeek = data["dayOfWeek"] as? Int,
|
|
let mealType = data["mealType"] as? String else { continue }
|
|
|
|
let existing = plan.slotList.first { $0.dayOfWeek == dayOfWeek && $0.mealType == mealType }
|
|
|
|
if data["deletedAt"] != nil {
|
|
if let existing {
|
|
plan.slotList.removeAll { $0.id == existing.id }
|
|
context.delete(existing)
|
|
}
|
|
shadow.remove(path: "weekPlans/\(weekKey)/slots/\(document.documentID)")
|
|
continue
|
|
}
|
|
|
|
let slot = existing ?? {
|
|
let created = MealSlot(dayOfWeek: dayOfWeek, mealType: mealType)
|
|
created.weekPlan = plan
|
|
plan.slotList.append(created)
|
|
context.insert(created)
|
|
return created
|
|
}()
|
|
HouseholdDocuments.apply(data, to: slot)
|
|
shadow.set(path: "weekPlans/\(weekKey)/slots/\(document.documentID)",
|
|
fingerprint: HouseholdDocuments.fingerprint(HouseholdDocuments.fields(for: slot)))
|
|
}
|
|
plan.updatedAt = Date()
|
|
try? context.save()
|
|
}
|
|
}
|
|
|
|
private func applyRemoteShoppingItems(_ snapshot: QuerySnapshot) {
|
|
guard let context else { return }
|
|
withRemoteApplication {
|
|
let existing = (try? context.fetch(FetchDescriptor<ShoppingItem>())) ?? []
|
|
var byId = Dictionary(existing.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
|
|
|
|
for document in snapshot.documents where !document.metadata.hasPendingWrites {
|
|
guard let id = UUID(uuidString: document.documentID) else { continue }
|
|
let data = document.data()
|
|
|
|
if data["deletedAt"] != nil {
|
|
if let item = byId[id] { context.delete(item) }
|
|
shadow.remove(path: "shoppingItems/\(document.documentID)")
|
|
continue
|
|
}
|
|
|
|
guard let weekStart = (data["weekStartDate"] as? Double).map({ Date(timeIntervalSince1970: $0) }) else {
|
|
continue
|
|
}
|
|
let item = byId[id] ?? {
|
|
let created = ShoppingItem(id: id, weekStartDate: weekStart, title: data["title"] as? String ?? "")
|
|
context.insert(created)
|
|
byId[id] = created
|
|
return created
|
|
}()
|
|
HouseholdDocuments.apply(data, to: item)
|
|
shadow.set(path: "shoppingItems/\(document.documentID)",
|
|
fingerprint: HouseholdDocuments.fingerprint(HouseholdDocuments.fields(for: item)))
|
|
}
|
|
try? context.save()
|
|
}
|
|
}
|
|
|
|
/// Suppresses the local-save trigger while remote changes are written, so
|
|
/// applying an incoming change doesn't bounce straight back as a push.
|
|
private func withRemoteApplication(_ body: () -> Void) {
|
|
isApplyingRemote = true
|
|
body()
|
|
isApplyingRemote = false
|
|
persistShadow()
|
|
lastSyncedAt = Date()
|
|
}
|
|
|
|
// MARK: - Local → remote
|
|
|
|
private func observeLocalSaves() {
|
|
saveObserver = NotificationCenter.default.addObserver(
|
|
forName: ModelContext.didSave,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
Task { @MainActor in
|
|
guard let self, !self.isApplyingRemote else { return }
|
|
self.schedulePush()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Debounced: a single user action can save several times in a row.
|
|
private func schedulePush() {
|
|
pushTask?.cancel()
|
|
pushTask = Task { [weak self] in
|
|
try? await Task.sleep(nanoseconds: 800_000_000)
|
|
guard !Task.isCancelled else { return }
|
|
await self?.pushLocalChanges()
|
|
}
|
|
}
|
|
|
|
/// Uploads everything whose fingerprint drifted from the shadow, and
|
|
/// tombstones what disappeared locally.
|
|
func pushLocalChanges() async {
|
|
guard let context, let householdId, let uid = AuthService.shared.uid else { return }
|
|
isSyncing = true
|
|
defer { isSyncing = false }
|
|
|
|
var documents: [String: [String: Any]] = [:]
|
|
|
|
for dish in (try? context.fetch(FetchDescriptor<Dish>())) ?? [] {
|
|
documents["dishes/\(dish.id.uuidString)"] = HouseholdDocuments.fields(for: dish)
|
|
}
|
|
for tag in (try? context.fetch(FetchDescriptor<Tag>())) ?? [] {
|
|
documents["tags/\(tag.id.uuidString)"] = HouseholdDocuments.fields(for: tag)
|
|
}
|
|
for item in (try? context.fetch(FetchDescriptor<ShoppingItem>())) ?? [] {
|
|
documents["shoppingItems/\(item.id.uuidString)"] = HouseholdDocuments.fields(for: item)
|
|
}
|
|
for plan in (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? [] {
|
|
let key = HouseholdDocuments.weekKey(for: plan.weekStartDate)
|
|
documents["weekPlans/\(key)"] = HouseholdDocuments.fields(for: plan)
|
|
for slot in plan.slotList {
|
|
let slotId = HouseholdDocuments.slotId(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType)
|
|
documents["weekPlans/\(key)/slots/\(slotId)"] = HouseholdDocuments.fields(for: slot)
|
|
}
|
|
}
|
|
|
|
var operations: [(path: String, fields: [String: Any], fingerprint: String?)] = []
|
|
|
|
for (path, fields) in documents {
|
|
let fingerprint = HouseholdDocuments.fingerprint(fields)
|
|
guard shadow.fingerprint(for: path) != fingerprint else { continue }
|
|
var payload = fields
|
|
payload["updatedAt"] = FieldValue.serverTimestamp()
|
|
payload["updatedBy"] = uid
|
|
payload["deletedAt"] = FieldValue.delete()
|
|
operations.append((path, payload, fingerprint))
|
|
}
|
|
|
|
for path in shadow.paths where documents[path] == nil {
|
|
operations.append((path, [
|
|
"deletedAt": FieldValue.serverTimestamp(),
|
|
"updatedBy": uid
|
|
], nil))
|
|
}
|
|
|
|
guard !operations.isEmpty else { return }
|
|
|
|
do {
|
|
// Firestore caps a batch at 500 writes.
|
|
for chunk in operations.chunked(into: 400) {
|
|
let batch = db.batch()
|
|
for operation in chunk {
|
|
let reference = db.document("households/\(householdId)/\(operation.path)")
|
|
batch.setData(operation.fields, forDocument: reference, merge: true)
|
|
}
|
|
try await batch.commit()
|
|
}
|
|
|
|
for operation in operations {
|
|
if let fingerprint = operation.fingerprint {
|
|
shadow.set(path: operation.path, fingerprint: fingerprint)
|
|
} else {
|
|
shadow.remove(path: operation.path)
|
|
}
|
|
}
|
|
persistShadow()
|
|
lastSyncedAt = Date()
|
|
lastError = nil
|
|
} catch {
|
|
lastError = error.localizedDescription
|
|
CrashlyticsService.record(error, context: "household_push")
|
|
}
|
|
}
|
|
|
|
// MARK: - Joining and leaving
|
|
|
|
/// Uploads the whole local store into a freshly created household.
|
|
func seedNewHousehold() async {
|
|
shadow.reset()
|
|
await pushLocalChanges()
|
|
}
|
|
|
|
/// Wipes the local synced content so the household's own content can take
|
|
/// its place. Used when someone joins and chooses not to bring their data.
|
|
func replaceLocalContent(context: ModelContext) {
|
|
withRemoteApplication {
|
|
for plan in (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? [] {
|
|
for slot in plan.slotList { context.delete(slot) }
|
|
context.delete(plan)
|
|
}
|
|
for dish in (try? context.fetch(FetchDescriptor<Dish>())) ?? [] { context.delete(dish) }
|
|
for tag in (try? context.fetch(FetchDescriptor<Tag>())) ?? [] { context.delete(tag) }
|
|
for item in (try? context.fetch(FetchDescriptor<ShoppingItem>())) ?? [] { context.delete(item) }
|
|
try? context.save()
|
|
}
|
|
shadow.reset()
|
|
}
|
|
|
|
private func persistShadow() {
|
|
guard let householdId else { return }
|
|
shadow.save(householdId: householdId)
|
|
}
|
|
}
|
|
|
|
// MARK: - Shadow
|
|
|
|
/// Last synced fingerprint per document path, persisted so a relaunch doesn't
|
|
/// re-upload the entire store.
|
|
struct SyncShadow {
|
|
private var fingerprints: [String: String] = [:]
|
|
|
|
var paths: [String] { Array(fingerprints.keys) }
|
|
|
|
func fingerprint(for path: String) -> String? { fingerprints[path] }
|
|
|
|
mutating func set(path: String, fingerprint: String) { fingerprints[path] = fingerprint }
|
|
|
|
mutating func remove(path: String) { fingerprints.removeValue(forKey: path) }
|
|
|
|
mutating func reset() { fingerprints = [:] }
|
|
|
|
private static func key(_ householdId: String) -> String { "household_shadow_\(householdId)" }
|
|
|
|
mutating func load(householdId: String) {
|
|
fingerprints = UserDefaults.standard.dictionary(forKey: Self.key(householdId)) as? [String: String] ?? [:]
|
|
}
|
|
|
|
func save(householdId: String) {
|
|
UserDefaults.standard.set(fingerprints, forKey: Self.key(householdId))
|
|
}
|
|
}
|
|
|
|
extension Array {
|
|
func chunked(into size: Int) -> [[Element]] {
|
|
guard size > 0 else { return [self] }
|
|
return stride(from: 0, to: count, by: size).map {
|
|
Array(self[$0..<Swift.min($0 + size, count)])
|
|
}
|
|
}
|
|
}
|