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
This commit is contained in:
alexandrev-tibco
2026-09-12 13:00:38 +02:00
parent 66128e7a5c
commit 163fd6026a
22 changed files with 2261 additions and 8 deletions
+2
View File
@@ -249,6 +249,7 @@ struct HomeView: View {
guard let settings = settings,
let plan = ensureCurrentWeekPlanExists(settings: settings) else { return }
viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context)
HouseholdSyncService.shared.focus(weekStart: viewModel.currentWeekStart)
}
}
@@ -794,6 +795,7 @@ struct HomeView: View {
language: settings.languageEnum.resolved()
)
wasWeekComplete = isWeekComplete(plan: plan)
HouseholdSyncService.shared.focus(weekStart: viewModel.currentWeekStart)
evaluatePostOnboardingPromptsIfNeeded(plan: plan, settings: settings)
evaluateWidgetPromo()
if settings.isPremium {
+309
View File
@@ -0,0 +1,309 @@
import SwiftUI
import SwiftData
import AuthenticationServices
/// Household screen: sign in, create or join a household, see who is in it and
/// leave. The content sync itself runs in `HouseholdSyncService`.
struct HouseholdView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@Query private var allSettings: [AppSettings]
@StateObject private var auth = AuthService.shared
@StateObject private var households = HouseholdService.shared
@StateObject private var sync = HouseholdSyncService.shared
@State private var householdName: String = ""
@State private var joinCode: String = ""
@State private var isWorking = false
@State private var errorMessage: String?
@State private var showJoinChoice = false
@State private var showLeaveConfirm = false
@State private var showPaywall = false
private var settings: AppSettings? { allSettings.first }
private var isPremium: Bool { settings?.isPremium ?? false }
private var isInHousehold: Bool { HouseholdRuntime.householdId != nil }
var body: some View {
Form {
if HouseholdRuntime.needsRelaunch {
Section {
Label("household_relaunch_needed", systemImage: "arrow.clockwise.circle.fill")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodWarning)
}
.listRowBackground(Color.mealMoodSurface)
}
if !auth.isSignedIn {
signInSection
} else if isInHousehold {
householdSection
membersSection
inviteSection
leaveSection
} else {
createSection
joinSection
}
if let errorMessage {
Section {
Text(errorMessage)
.font(.mealMoodCaption)
.foregroundColor(.mealMoodWarning)
}
.listRowBackground(Color.mealMoodSurface)
}
}
.scrollContentBackground(.hidden)
.background(Color.mealMoodBackground.ignoresSafeArea())
.tint(.mealMoodCoral)
.navigationTitle("household_title")
.navigationBarTitleDisplayMode(.inline)
.disabled(isWorking)
.onAppear {
households.start()
AnalyticsService.logScreenView("Household")
}
.sheet(isPresented: $showPaywall) {
if let settings {
NavigationStack {
PremiumView(settings: settings, source: "household")
}
}
}
.confirmationDialog("household_join_choice_title", isPresented: $showJoinChoice, titleVisibility: .visible) {
Button("household_join_choice_merge") { join(bringingLocalContent: true) }
Button("household_join_choice_replace", role: .destructive) { join(bringingLocalContent: false) }
Button("reset_cancel", role: .cancel) {}
} message: {
Text("household_join_choice_message")
}
.alert("household_leave_confirm_title", isPresented: $showLeaveConfirm) {
Button("reset_cancel", role: .cancel) {}
Button("household_leave_confirm_confirm", role: .destructive) { leave() }
} message: {
Text("household_leave_confirm_message")
}
}
// MARK: - Sections
private var signInSection: some View {
Section {
Text("household_intro")
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
SignInWithAppleButton(.signIn) { request in
auth.prepare(request: request)
} onCompletion: { result in
Task {
do {
try await auth.completeSignInWithApple(result)
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
}
.signInWithAppleButtonStyle(.black)
.frame(height: 46)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
} footer: {
Text("household_sign_in_footer")
}
.listRowBackground(Color.mealMoodSurface)
}
private var createSection: some View {
Section {
TextField("household_name_placeholder", text: $householdName)
Button {
guard isPremium else {
showPaywall = true
AnalyticsService.logEvent("paywall_viewed", parameters: ["source": "household_create"])
return
}
create()
} label: {
HStack {
Label("household_create", systemImage: "house.fill")
if !isPremium {
Spacer()
Image(systemName: "star.circle.fill").foregroundColor(.mealMoodCoral)
}
}
}
.disabled(householdName.trimmingCharacters(in: .whitespaces).isEmpty)
} header: {
Text("household_create_section")
} footer: {
if isPremium {
Text("household_create_footer")
} else {
Text("household_create_premium_footer")
}
}
.listRowBackground(Color.mealMoodSurface)
}
private var joinSection: some View {
Section {
TextField("household_code_placeholder", text: $joinCode)
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
Button {
showJoinChoice = true
} label: {
Label("household_join", systemImage: "person.badge.plus")
}
.disabled(!HouseholdService.isPlausibleCode(joinCode))
} header: {
Text("household_join_section")
} footer: {
Text("household_join_footer")
}
.listRowBackground(Color.mealMoodSurface)
}
private var householdSection: some View {
Section {
HStack {
Label(households.household?.name ?? HouseholdRuntime.householdName ?? "", systemImage: "house.fill")
Spacer()
if sync.isSyncing {
ProgressView()
} else if let lastSyncedAt = sync.lastSyncedAt {
Text(lastSyncedAt.formatted(date: .omitted, time: .shortened))
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
}
}
if let syncError = sync.lastError {
Text(syncError)
.font(.mealMoodCaption)
.foregroundColor(.mealMoodWarning)
}
} header: {
Text("household_section")
}
.listRowBackground(Color.mealMoodSurface)
}
private var membersSection: some View {
Section {
ForEach(households.members) { member in
HStack {
Label(
member.displayName.isEmpty ? String(localized: "household_member_unnamed") : member.displayName,
systemImage: member.role == "owner" ? "crown.fill" : "person.fill"
)
Spacer()
if member.id == auth.uid {
Text("household_member_you")
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
}
}
}
} header: {
Text("household_members_section")
}
.listRowBackground(Color.mealMoodSurface)
}
private var inviteSection: some View {
Section {
if let code = households.household?.inviteCode {
HStack {
Text(code)
.font(.system(.title3, design: .monospaced))
.fontWeight(.bold)
.foregroundColor(.mealMoodTextPrimary)
Spacer()
ShareLink(item: String(format: String(localized: "household_invite_share"), code)) {
Image(systemName: "square.and.arrow.up")
}
}
if let expiry = households.household?.inviteExpiresAt {
Text(String(format: String(localized: "household_invite_expires"),
expiry.formatted(date: .abbreviated, time: .omitted)))
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
}
}
Button {
regenerateCode()
} label: {
Label("household_invite_regenerate", systemImage: "arrow.triangle.2.circlepath")
}
} header: {
Text("household_invite_section")
} footer: {
Text("household_invite_footer")
}
.listRowBackground(Color.mealMoodSurface)
}
private var leaveSection: some View {
Section {
Button(role: .destructive) {
showLeaveConfirm = true
} label: {
Label("household_leave", systemImage: "rectangle.portrait.and.arrow.right")
}
}
.listRowBackground(Color.mealMoodSurface)
}
// MARK: - Actions
private func create() {
run {
let name = householdName.trimmingCharacters(in: .whitespaces)
_ = try await households.createHousehold(name: name, displayName: auth.displayName ?? "")
// Everything already planned on this device becomes the household's
// starting point.
await sync.seedNewHousehold()
}
}
private func join(bringingLocalContent: Bool) {
run {
_ = try await households.join(code: joinCode, displayName: auth.displayName ?? "")
if !bringingLocalContent {
sync.replaceLocalContent(context: context)
}
joinCode = ""
}
}
private func leave() {
run {
try await households.leaveHousehold()
}
}
private func regenerateCode() {
run {
try await households.regenerateInviteCode()
}
}
private func run(_ operation: @escaping () async throws -> Void) {
isWorking = true
errorMessage = nil
Task {
do {
try await operation()
} catch {
errorMessage = error.localizedDescription
CrashlyticsService.record(error, context: "household_action")
}
isWorking = false
}
}
}
@@ -227,6 +227,26 @@ struct SettingsView: View {
.listRowBackground(Color.mealMoodSurface)
.task { await refreshNotificationStatus() }
// Household section (2.2): shared planning across accounts.
Section {
NavigationLink(destination: HouseholdView()) {
HStack {
Text("household_title")
Spacer()
if let name = HouseholdRuntime.householdName, HouseholdRuntime.householdId != nil {
Text(name)
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
}
}
}
} header: {
Label("household_section", systemImage: "person.2")
} footer: {
Text("household_settings_footer")
}
.listRowBackground(Color.mealMoodSurface)
// Tags section
Section {
NavigationLink(destination: TagListView()) {