Files
FamilyMealPlanner/MealMood/Services/Household/HouseholdService.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

282 lines
10 KiB
Swift

import Foundation
import FirebaseFirestore
struct HouseholdSummary: Identifiable, Equatable {
let id: String
var name: String
var ownerId: String
var memberIds: [String]
var inviteCode: String?
var inviteExpiresAt: Date?
}
struct HouseholdMember: Identifiable, Equatable {
let id: String // uid
var displayName: String
var role: String // "owner" | "member"
var joinedAt: Date
}
/// Creating a household, inviting people into it and leaving it. The content
/// sync itself lives in `HouseholdSyncService`.
@MainActor
final class HouseholdService: ObservableObject {
static let shared = HouseholdService()
private let db = Firestore.firestore()
@Published private(set) var household: HouseholdSummary?
@Published private(set) var members: [HouseholdMember] = []
private var householdListener: ListenerRegistration?
private var membersListener: ListenerRegistration?
private init() {}
// MARK: - Lifecycle
/// Starts watching the household this device belongs to, if any.
func start() {
guard let householdId = HouseholdRuntime.householdId else { return }
observe(householdId: householdId)
}
func stop() {
householdListener?.remove()
membersListener?.remove()
householdListener = nil
membersListener = nil
household = nil
members = []
}
private func observe(householdId: String) {
householdListener?.remove()
membersListener?.remove()
householdListener = db.collection("households").document(householdId)
.addSnapshotListener { [weak self] snapshot, _ in
guard let data = snapshot?.data() else { return }
Task { @MainActor in
self?.household = Self.summary(id: householdId, data: data)
HouseholdRuntime.householdName = data["name"] as? String
}
}
membersListener = db.collection("households").document(householdId).collection("members")
.addSnapshotListener { [weak self] snapshot, _ in
let members = (snapshot?.documents ?? []).map { document -> HouseholdMember in
let data = document.data()
return HouseholdMember(
id: document.documentID,
displayName: data["displayName"] as? String ?? "",
role: data["role"] as? String ?? "member",
joinedAt: (data["joinedAt"] as? Timestamp)?.dateValue() ?? Date()
)
}
Task { @MainActor in
self?.members = members.sorted { $0.joinedAt < $1.joinedAt }
}
}
}
private static func summary(id: String, data: [String: Any]) -> HouseholdSummary {
HouseholdSummary(
id: id,
name: data["name"] as? String ?? "",
ownerId: data["ownerId"] as? String ?? "",
memberIds: data["memberIds"] as? [String] ?? [],
inviteCode: data["inviteCode"] as? String,
inviteExpiresAt: (data["inviteExpiresAt"] as? Timestamp)?.dateValue()
)
}
// MARK: - Create / join / leave
func createHousehold(name: String, displayName: String) async throws -> HouseholdSummary {
guard let uid = AuthService.shared.uid else { throw HouseholdError.notSignedIn }
let householdId = UUID().uuidString
let code = Self.makeInviteCode()
let expiresAt = Date().addingTimeInterval(Self.inviteLifetime)
let householdRef = db.collection("households").document(householdId)
let batch = db.batch()
batch.setData([
"name": name,
"ownerId": uid,
"memberIds": [uid],
"createdBy": uid,
"createdAt": FieldValue.serverTimestamp(),
"inviteCode": code,
"inviteExpiresAt": Timestamp(date: expiresAt)
], forDocument: householdRef)
batch.setData([
"displayName": displayName,
"role": "owner",
"joinedAt": FieldValue.serverTimestamp()
], forDocument: householdRef.collection("members").document(uid))
batch.setData([
"householdId": householdId,
"createdBy": uid,
"expiresAt": Timestamp(date: expiresAt)
], forDocument: db.collection("invites").document(code))
batch.setData([
"householdId": householdId,
"displayName": displayName,
"updatedAt": FieldValue.serverTimestamp()
], forDocument: db.collection("users").document(uid), merge: true)
try await batch.commit()
HouseholdRuntime.householdId = householdId
HouseholdRuntime.householdName = name
observe(householdId: householdId)
AnalyticsService.logEvent("household_created")
return HouseholdSummary(
id: householdId, name: name, ownerId: uid, memberIds: [uid],
inviteCode: code, inviteExpiresAt: expiresAt
)
}
func join(code rawCode: String, displayName: String) async throws -> HouseholdSummary {
guard let uid = AuthService.shared.uid else { throw HouseholdError.notSignedIn }
let code = Self.normalize(rawCode)
let inviteSnapshot = try await db.collection("invites").document(code).getDocument()
guard let invite = inviteSnapshot.data(),
let householdId = invite["householdId"] as? String else {
throw HouseholdError.invalidCode
}
if let expiresAt = (invite["expiresAt"] as? Timestamp)?.dateValue(), expiresAt < Date() {
throw HouseholdError.expiredCode
}
let householdRef = db.collection("households").document(householdId)
// `lastJoinCode` is what the security rules check to allow a stranger to
// add themselves to `memberIds` see firestore.rules.
try await householdRef.updateData([
"memberIds": FieldValue.arrayUnion([uid]),
"lastJoinCode": code
])
try await householdRef.collection("members").document(uid).setData([
"displayName": displayName,
"role": "member",
"joinedAt": FieldValue.serverTimestamp()
])
try await db.collection("users").document(uid).setData([
"householdId": householdId,
"displayName": displayName,
"updatedAt": FieldValue.serverTimestamp()
], merge: true)
let snapshot = try await householdRef.getDocument()
let summary = Self.summary(id: householdId, data: snapshot.data() ?? [:])
HouseholdRuntime.householdId = householdId
HouseholdRuntime.householdName = summary.name
observe(householdId: householdId)
AnalyticsService.logEvent("household_joined")
return summary
}
func leaveHousehold() async throws {
guard let uid = AuthService.shared.uid else { throw HouseholdError.notSignedIn }
guard let householdId = HouseholdRuntime.householdId else { return }
let householdRef = db.collection("households").document(householdId)
let snapshot = try await householdRef.getDocument()
let summary = Self.summary(id: householdId, data: snapshot.data() ?? [:])
let remaining = summary.memberIds.filter { $0 != uid }
if remaining.isEmpty {
// Last one out: the household and its content go with them.
try await householdRef.delete()
} else {
var updates: [String: Any] = ["memberIds": FieldValue.arrayRemove([uid])]
// Hand ownership over instead of leaving an orphan household.
if summary.ownerId == uid, let heir = remaining.first {
updates["ownerId"] = heir
}
try await householdRef.updateData(updates)
try await householdRef.collection("members").document(uid).delete()
}
try await db.collection("users").document(uid).setData([
"householdId": FieldValue.delete()
], merge: true)
stop()
HouseholdRuntime.clear()
AnalyticsService.logEvent("household_left")
}
/// Issues a fresh invite code, invalidating the previous one.
@discardableResult
func regenerateInviteCode() async throws -> String {
guard let uid = AuthService.shared.uid else { throw HouseholdError.notSignedIn }
guard let householdId = HouseholdRuntime.householdId else { throw HouseholdError.noHousehold }
let code = Self.makeInviteCode()
let expiresAt = Date().addingTimeInterval(Self.inviteLifetime)
let previousCode = household?.inviteCode
try await db.collection("invites").document(code).setData([
"householdId": householdId,
"createdBy": uid,
"expiresAt": Timestamp(date: expiresAt)
])
try await db.collection("households").document(householdId).updateData([
"inviteCode": code,
"inviteExpiresAt": Timestamp(date: expiresAt)
])
if let previousCode, previousCode != code {
try? await db.collection("invites").document(previousCode).delete()
}
return code
}
// MARK: - Invite codes
nonisolated static let inviteLifetime: TimeInterval = 7 * 24 * 60 * 60
/// Six characters, no vowels (no accidental words) and no 0/O/1/I so they
/// survive being read out loud or typed from a screenshot.
nonisolated static let inviteAlphabet = Array("23456789BCDFGHJKLMNPQRSTVWXYZ")
nonisolated static func makeInviteCode() -> String {
String((0..<6).map { _ in inviteAlphabet.randomElement() ?? "X" })
}
/// Uppercases and drops anything outside the alphabet, so "bcdf-gh" and
/// "BCDF GH" both reach Firestore as "BCDFGH".
nonisolated static func normalize(_ code: String) -> String {
String(code.uppercased().filter { inviteAlphabet.contains($0) })
}
nonisolated static func isPlausibleCode(_ code: String) -> Bool {
normalize(code).count == 6
}
enum HouseholdError: LocalizedError {
case notSignedIn
case noHousehold
case invalidCode
case expiredCode
var errorDescription: String? {
switch self {
case .notSignedIn: return String(localized: "household_error_not_signed_in")
case .noHousehold: return String(localized: "household_error_no_household")
case .invalidCode: return String(localized: "household_error_invalid_code")
case .expiredCode: return String(localized: "household_error_expired_code")
}
}
}
}