Files
FamilyMealPlanner/MealMood/Services/Household/HouseholdSyncService.swift
T
alexandrev-tibco 163fd6026a hogar compartido: colaboracion entre cuentas sobre Firestore
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
2026-09-12 13:00:38 +02:00

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)])
}
}
}