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
282 lines
10 KiB
Swift
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")
|
|
}
|
|
}
|
|
}
|
|
}
|