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
113 lines
4.9 KiB
Markdown
113 lines
4.9 KiB
Markdown
# Hogar compartido (2.2) — diseño
|
|
|
|
Colaboración entre cuentas distintas y acceso desde Android. Issue #33.
|
|
|
|
## Por qué no CloudKit
|
|
|
|
El store es hoy `NSPersistentCloudKitContainer` (SwiftData con
|
|
`cloudKitDatabase: .automatic`), que sincroniza la base de datos **privada** de
|
|
un Apple ID entre los dispositivos de esa persona. Dos cosas lo descartan aquí:
|
|
|
|
- No existe en Android.
|
|
- Compartir entre cuentas exige `CKShare`, que SwiftData no expone (ya se
|
|
intentó en la 2.0 y se aparcó a la 2.1).
|
|
|
|
## Arquitectura
|
|
|
|
```
|
|
┌──────────────────────────┐
|
|
iOS / iPadOS │ SwiftData (store local) │ ← toda la UI sigue leyendo de aquí (@Query)
|
|
└────────────┬─────────────┘
|
|
│ HouseholdSyncService (espejo bidireccional)
|
|
┌────────────┴─────────────┐
|
|
│ Firestore (el hogar) │ ← fuente de verdad cuando hay hogar
|
|
└────────────┬─────────────┘
|
|
│
|
|
Android / web │ PWA (fase 2), lee y escribe el mismo hogar
|
|
```
|
|
|
|
SwiftData **no** se sustituye: sigue siendo el store local y el modo offline.
|
|
Firestore es la fuente de verdad del contenido del hogar, y el servicio de sync
|
|
mantiene ambos lados alineados.
|
|
|
|
### CloudKit y Firestore no conviven
|
|
|
|
Dos sincronizaciones sobre los mismos objetos se pelean — ya pasó con el sync
|
|
por iCloud KV, y por eso existe `CloudSyncRuntime.isCloudKitActive`. Regla:
|
|
|
|
- Sin hogar → como hoy: CloudKit activo, Firestore inactivo.
|
|
- Con hogar → contenedor local (`cloudKitDatabase: .none`) y Firestore manda.
|
|
|
|
El contenedor se decide en el arranque, así que al entrar o salir de un hogar
|
|
la app pide relanzarse. Es un corte visible, pero la alternativa (dos orígenes
|
|
escribiendo los mismos objetos, con ecos entre ellos) es una fuente de pérdida
|
|
de datos.
|
|
|
|
## Modelo en Firestore
|
|
|
|
```
|
|
users/{uid}
|
|
householdId, displayName, updatedAt
|
|
|
|
invites/{code} ← resuelve código → hogar sin leer todos los hogares
|
|
householdId, createdBy, expiresAt
|
|
|
|
households/{householdId}
|
|
name, createdAt, createdBy, memberIds: [uid], ownerId
|
|
members/{uid} displayName, role: owner|member, joinedAt
|
|
dishes/{uuid} name, descriptionText, tagIds[], ingredients[], isPriority,
|
|
fixedDayOfWeek, fixedMealType, updatedAt, updatedBy, deletedAt?
|
|
tags/{uuid} name, nameEN, color, reglas…, updatedAt, updatedBy, deletedAt?
|
|
weekPlans/{yyyy-MM-dd} weekStartDate, userRating, includeWeekendsOverride,
|
|
mealTypesOverrideRaw, updatedAt, updatedBy
|
|
slots/{day}-{meal} dishId, secondaryDishId, isEatingOut, isSkipped,
|
|
isRuleOverridden, isRuleIgnored, updatedAt, updatedBy
|
|
shoppingItems/{uuid} weekStartDate, title, dishId, isChecked, isDismissed,
|
|
sortOrder, updatedAt, updatedBy, deletedAt?
|
|
```
|
|
|
|
### IDs deterministas donde importa
|
|
|
|
Semanas y slots usan id derivado del contenido (`2026-09-14`, `5-dinner`) en vez
|
|
de UUID. Si dos miembros abren la misma semana a la vez, ambos escriben el mismo
|
|
documento en lugar de crear dos. Es la misma clase de duplicado que costó el
|
|
crash de arranque de la 2.0, pero resuelta en el origen.
|
|
|
|
Platos, etiquetas y líneas de la compra conservan su UUID: los crea una persona
|
|
concreta y no hay id natural.
|
|
|
|
## Conflictos
|
|
|
|
Last-write-wins por **documento**, con `updatedAt` de servidor
|
|
(`FieldValue.serverTimestamp()`) y `updatedBy` para poder depurar. La
|
|
granularidad importa: el documento es el slot, no la semana, así que dos
|
|
personas pueden rellenar martes y jueves a la vez sin pisarse.
|
|
|
|
Los borrados son tombstones (`deletedAt`), no borrados duros: sin ellos, un
|
|
dispositivo offline que no vio el borrado recrearía el plato al volver.
|
|
|
|
## Alcance de la fase 1
|
|
|
|
Dentro:
|
|
|
|
- Cuentas (Sign in with Apple + Google) y hogar con invitación por código.
|
|
- Sync de platos, etiquetas, semanas, slots y lista de la compra.
|
|
- Desactivación de CloudKit al entrar en un hogar.
|
|
|
|
Fuera, a propósito:
|
|
|
|
- **Fotos de platos**: `photoData` va a `@Attribute(.externalStorage)` y un
|
|
documento de Firestore tope a 1 MB. Van a Firebase Storage en fase 2.
|
|
- **Ajustes personales** (`AppSettings`): no se comparten. Idioma, horas de
|
|
calendario o estilo de export son de cada persona. Lo que sí es del hogar
|
|
(días y comidas planificadas) viaja en el propio `weekPlan`.
|
|
- **PWA de Android**: fase 2, sobre este mismo modelo.
|
|
|
|
## Premium
|
|
|
|
Crear un hogar requiere premium. Los invitados entran sin premium — es el gancho
|
|
del plan familiar, y quien invita ya paga. El resto de features premium siguen
|
|
bloqueadas para el invitado.
|
|
|
|
`isPremium` sigue viniendo solo de StoreKit y nunca se sincroniza, igual que hoy.
|