Revert "hogar compartido: colaboracion entre cuentas sobre Firestore"

El hogar compartido cambia donde viven los datos, asi que se va a la 3.0 y la
2.x queda para correcciones. El trabajo sigue vivo en la rama 3.0.0 (163fd60).

Importante para esta rama: esto tambien retira el entitlement
com.apple.developer.applesignin, que sin la capability marcada en el App ID
habria roto la firma al subir la 2.1.1 a TestFlight.

This reverts commit 163fd60.

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:08:27 +02:00
parent 163fd6026a
commit d8345a5b20
22 changed files with 8 additions and 2261 deletions
-5
View File
@@ -33,11 +33,6 @@ struct ContentView: View {
// Activate WatchConnectivity as early as possible so the first widget
// refresh doesn't race the session activation.
.onAppear { WatchSyncService.shared.activate() }
// 2.2: household content syncs through Firestore instead of CloudKit.
.onAppear {
HouseholdService.shared.start()
HouseholdSyncService.shared.start(context: context)
}
// This is the most reliable way to catch a purchase even if the app was
// interrupted during the payment flow.
.task {
+8 -16
View File
@@ -49,25 +49,17 @@ struct MealMoodApp: App {
ShoppingItem.self
])
// 2.2: inside a household the data lives in Firestore (shared with the
// other members, reachable from Android) and the store stays local.
// Running CloudKit on top would mean two mirrors writing the same
// objects. See docs/household-sync.md.
HouseholdRuntime.isHouseholdStore = HouseholdRuntime.householdId != nil
// 2.0: CloudKit private-database sync (multi-device, same Apple ID).
// Falls back to the local-only store when CloudKit isn't available
// (signed-out iCloud, missing entitlement in dev builds, etc.).
if !HouseholdRuntime.isHouseholdStore {
let cloudConfiguration = ModelConfiguration(cloudKitDatabase: .automatic)
do {
let container = try ModelContainer(for: schema, configurations: [cloudConfiguration])
CloudSyncRuntime.isCloudKitActive = true
return container
} catch {
CrashlyticsService.record(error, context: "cloudkit_container")
print("CloudKit container unavailable, using local store: \(error)")
}
let cloudConfiguration = ModelConfiguration(cloudKitDatabase: .automatic)
do {
let container = try ModelContainer(for: schema, configurations: [cloudConfiguration])
CloudSyncRuntime.isCloudKitActive = true
return container
} catch {
CrashlyticsService.record(error, context: "cloudkit_container")
print("CloudKit container unavailable, using local store: \(error)")
}
let configuration = ModelConfiguration(cloudKitDatabase: .none)
-4
View File
@@ -14,10 +14,6 @@
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.alexandrevazquez.mealmood</string>
@@ -478,40 +478,3 @@
"week_schedule_warning_confirm" = "Trotzdem entfernen";
"toast_week_schedule_updated" = "Woche aktualisiert";
"toast_week_schedule_reset" = "Woche folgt wieder deinen Einstellungen";
/* Hogar compartido (2.2) */
"household_title" = "Gemeinsamer Haushalt";
"household_section" = "Haushalt";
"household_settings_footer" = "Plant die Woche gemeinsam, jede Person mit eigenem Konto. Funktioniert auch unter Android.";
"household_intro" = "In einem Haushalt planen mehrere Personen dieselbe Woche und teilen sich Gerichte und Einkaufsliste.";
"household_sign_in_footer" = "Zum Teilen musst du dich anmelden. Deine Gerichte bleiben deine: außerhalb deines Haushalts sieht sie niemand.";
"household_create_section" = "Haushalt erstellen";
"household_create" = "Haushalt erstellen";
"household_name_placeholder" = "Name des Haushalts (z. B. Zuhause)";
"household_create_footer" = "Deine aktuellen Gerichte, Tags und Wochen werden zum Ausgangspunkt des Haushalts.";
"household_create_premium_footer" = "Einen Haushalt zu erstellen ist Premium. Wer eingeladen wird, zahlt nichts.";
"household_join_section" = "Haushalt beitreten";
"household_join" = "Mit Code beitreten";
"household_code_placeholder" = "Einladungscode";
"household_join_footer" = "Frag die Person, die den Haushalt erstellt hat, nach dem Code. Er läuft nach 7 Tagen ab.";
"household_join_choice_title" = "Was ist mit deinen Gerichten?";
"household_join_choice_message" = "Du kannst deine Gerichte und Wochen in diesen Haushalt mitnehmen oder nur mit denen des Haushalts starten.";
"household_join_choice_merge" = "Meine Gerichte mitnehmen";
"household_join_choice_replace" = "Nur die des Haushalts";
"household_members_section" = "Mitglieder";
"household_member_you" = "du";
"household_member_unnamed" = "Ohne Namen";
"household_invite_section" = "Einladung";
"household_invite_footer" = "Teile diesen Code mit allen, die mit dir planen sollen.";
"household_invite_share" = "Komm in meinen MealMood-Haushalt mit dem Code %@ — https://mealmood.app";
"household_invite_expires" = "Läuft ab am %@";
"household_invite_regenerate" = "Neuen Code erzeugen";
"household_leave" = "Haushalt verlassen";
"household_leave_confirm_title" = "Haushalt verlassen?";
"household_leave_confirm_message" = "Du siehst und bearbeitest die Gerichte und Wochen des Haushalts auf diesem Gerät nicht mehr.";
"household_leave_confirm_confirm" = "Verlassen";
"household_relaunch_needed" = "Schließe MealMood und öffne es erneut, um den Wechsel abzuschließen.";
"household_error_not_signed_in" = "Melde dich an, bevor du einen Haushalt erstellst oder beitrittst.";
"household_error_no_household" = "Dieses Gerät gehört zu keinem Haushalt.";
"household_error_invalid_code" = "Diesen Code gibt es nicht.";
"household_error_expired_code" = "Dieser Code ist abgelaufen. Bitte um einen neuen.";
@@ -478,40 +478,3 @@
"week_schedule_warning_confirm" = "Remove anyway";
"toast_week_schedule_updated" = "Week updated";
"toast_week_schedule_reset" = "Week back to your general settings";
/* Hogar compartido (2.2) */
"household_title" = "Shared household";
"household_section" = "Household";
"household_settings_footer" = "Plan the week together, each person from their own account. Works from Android too.";
"household_intro" = "A household lets several people plan the same week, sharing dishes and the shopping list.";
"household_sign_in_footer" = "Signing in is what makes sharing possible. Your dishes stay yours: nobody outside your household sees them.";
"household_create_section" = "Create a household";
"household_create" = "Create household";
"household_name_placeholder" = "Household name (e.g. Home)";
"household_create_footer" = "Your current dishes, tags and weeks become the household's starting point.";
"household_create_premium_footer" = "Creating a household is Premium. Whoever you invite doesn't have to pay.";
"household_join_section" = "Join a household";
"household_join" = "Join with code";
"household_code_placeholder" = "Invite code";
"household_join_footer" = "Ask whoever created the household for the code. It expires after 7 days.";
"household_join_choice_title" = "What about your dishes?";
"household_join_choice_message" = "You can bring the dishes and weeks you already have into this household, or start with the household's own.";
"household_join_choice_merge" = "Bring my dishes";
"household_join_choice_replace" = "Use the household's only";
"household_members_section" = "Members";
"household_member_you" = "you";
"household_member_unnamed" = "Unnamed";
"household_invite_section" = "Invitation";
"household_invite_footer" = "Share this code with whoever you want planning with you.";
"household_invite_share" = "Join my MealMood household with the code %@ — https://mealmood.app";
"household_invite_expires" = "Expires on %@";
"household_invite_regenerate" = "Generate a new code";
"household_leave" = "Leave household";
"household_leave_confirm_title" = "Leave the household?";
"household_leave_confirm_message" = "You'll stop seeing and editing the household's dishes and weeks on this device.";
"household_leave_confirm_confirm" = "Leave";
"household_relaunch_needed" = "Quit and reopen MealMood to finish the change.";
"household_error_not_signed_in" = "Sign in before creating or joining a household.";
"household_error_no_household" = "This device doesn't belong to a household.";
"household_error_invalid_code" = "That code doesn't exist.";
"household_error_expired_code" = "That code has expired. Ask for a new one.";
@@ -478,40 +478,3 @@
"week_schedule_warning_confirm" = "Quitar igualmente";
"toast_week_schedule_updated" = "Semana actualizada";
"toast_week_schedule_reset" = "Semana con tu configuración general";
/* Hogar compartido (2.2) */
"household_title" = "Hogar compartido";
"household_section" = "Hogar";
"household_settings_footer" = "Planificad la semana entre varias personas, cada una desde su cuenta. Funciona también desde Android.";
"household_intro" = "Un hogar permite que varias personas planifiquen la misma semana, con sus platos y su lista de la compra en común.";
"household_sign_in_footer" = "Necesitas identificarte para compartir con otra persona. Tus platos siguen siendo tuyos: nadie fuera de tu hogar los ve.";
"household_create_section" = "Crear un hogar";
"household_create" = "Crear hogar";
"household_name_placeholder" = "Nombre del hogar (p. ej. Casa)";
"household_create_footer" = "Tus platos, etiquetas y semanas actuales pasan a ser el punto de partida del hogar.";
"household_create_premium_footer" = "Crear un hogar es Premium. Quien recibe la invitación no necesita pagar.";
"household_join_section" = "Unirme a un hogar";
"household_join" = "Unirme con el código";
"household_code_placeholder" = "Código de invitación";
"household_join_footer" = "Pide el código a quien creó el hogar. Caduca a los 7 días.";
"household_join_choice_title" = "¿Qué hacemos con tus platos?";
"household_join_choice_message" = "Puedes llevarte a este hogar los platos y semanas que ya tienes, o empezar solo con los del hogar.";
"household_join_choice_merge" = "Llevarme mis platos";
"household_join_choice_replace" = "Usar solo los del hogar";
"household_members_section" = "Miembros";
"household_member_you" = "tú";
"household_member_unnamed" = "Sin nombre";
"household_invite_section" = "Invitación";
"household_invite_footer" = "Comparte este código con quien quieras que planifique contigo.";
"household_invite_share" = "Únete a mi hogar en MealMood con el código %@ — https://mealmood.app";
"household_invite_expires" = "Caduca el %@";
"household_invite_regenerate" = "Generar código nuevo";
"household_leave" = "Salir del hogar";
"household_leave_confirm_title" = "¿Salir del hogar?";
"household_leave_confirm_message" = "Dejarás de ver y de editar los platos y las semanas del hogar en este dispositivo.";
"household_leave_confirm_confirm" = "Salir";
"household_relaunch_needed" = "Cierra y vuelve a abrir MealMood para terminar el cambio.";
"household_error_not_signed_in" = "Identifícate antes de crear o unirte a un hogar.";
"household_error_no_household" = "Este dispositivo no pertenece a ningún hogar.";
"household_error_invalid_code" = "Ese código no existe.";
"household_error_expired_code" = "Ese código ha caducado. Pide uno nuevo.";
@@ -478,40 +478,3 @@
"week_schedule_warning_confirm" = "Retirer quand même";
"toast_week_schedule_updated" = "Semaine mise à jour";
"toast_week_schedule_reset" = "Semaine revenue à vos réglages généraux";
/* Hogar compartido (2.2) */
"household_title" = "Foyer partagé";
"household_section" = "Foyer";
"household_settings_footer" = "Planifiez la semaine à plusieurs, chacun depuis son compte. Fonctionne aussi depuis Android.";
"household_intro" = "Un foyer permet à plusieurs personnes de planifier la même semaine, avec les plats et la liste de courses en commun.";
"household_sign_in_footer" = "Il faut se connecter pour partager. Vos plats restent les vôtres : personne en dehors de votre foyer ne les voit.";
"household_create_section" = "Créer un foyer";
"household_create" = "Créer le foyer";
"household_name_placeholder" = "Nom du foyer (ex. Maison)";
"household_create_footer" = "Vos plats, étiquettes et semaines actuels deviennent le point de départ du foyer.";
"household_create_premium_footer" = "Créer un foyer est réservé au Premium. La personne invitée n'a rien à payer.";
"household_join_section" = "Rejoindre un foyer";
"household_join" = "Rejoindre avec le code";
"household_code_placeholder" = "Code d'invitation";
"household_join_footer" = "Demandez le code à la personne qui a créé le foyer. Il expire au bout de 7 jours.";
"household_join_choice_title" = "Et vos plats ?";
"household_join_choice_message" = "Vous pouvez emmener vos plats et vos semaines dans ce foyer, ou commencer avec ceux du foyer uniquement.";
"household_join_choice_merge" = "Emmener mes plats";
"household_join_choice_replace" = "Garder ceux du foyer";
"household_members_section" = "Membres";
"household_member_you" = "vous";
"household_member_unnamed" = "Sans nom";
"household_invite_section" = "Invitation";
"household_invite_footer" = "Partagez ce code avec les personnes qui planifieront avec vous.";
"household_invite_share" = "Rejoins mon foyer MealMood avec le code %@ — https://mealmood.app";
"household_invite_expires" = "Expire le %@";
"household_invite_regenerate" = "Générer un nouveau code";
"household_leave" = "Quitter le foyer";
"household_leave_confirm_title" = "Quitter le foyer ?";
"household_leave_confirm_message" = "Vous ne verrez plus et ne pourrez plus modifier les plats et les semaines du foyer sur cet appareil.";
"household_leave_confirm_confirm" = "Quitter";
"household_relaunch_needed" = "Fermez et rouvrez MealMood pour terminer le changement.";
"household_error_not_signed_in" = "Connectez-vous avant de créer un foyer ou d'en rejoindre un.";
"household_error_no_household" = "Cet appareil n'appartient à aucun foyer.";
"household_error_invalid_code" = "Ce code n'existe pas.";
"household_error_expired_code" = "Ce code a expiré. Demandez-en un nouveau.";
@@ -478,40 +478,3 @@
"week_schedule_warning_confirm" = "Rimuovi comunque";
"toast_week_schedule_updated" = "Settimana aggiornata";
"toast_week_schedule_reset" = "Settimana tornata alle impostazioni generali";
/* Hogar compartido (2.2) */
"household_title" = "Casa condivisa";
"household_section" = "Casa";
"household_settings_footer" = "Pianificate la settimana insieme, ognuno dal proprio account. Funziona anche da Android.";
"household_intro" = "Una casa permette a più persone di pianificare la stessa settimana, condividendo piatti e lista della spesa.";
"household_sign_in_footer" = "Per condividere serve accedere. I tuoi piatti restano tuoi: fuori dalla tua casa non li vede nessuno.";
"household_create_section" = "Creare una casa";
"household_create" = "Crea casa";
"household_name_placeholder" = "Nome della casa (es. Casa)";
"household_create_footer" = "I tuoi piatti, le etichette e le settimane attuali diventano il punto di partenza della casa.";
"household_create_premium_footer" = "Creare una casa è Premium. Chi ricevi l'invito non deve pagare.";
"household_join_section" = "Unirsi a una casa";
"household_join" = "Unisciti con il codice";
"household_code_placeholder" = "Codice d'invito";
"household_join_footer" = "Chiedi il codice a chi ha creato la casa. Scade dopo 7 giorni.";
"household_join_choice_title" = "E i tuoi piatti?";
"household_join_choice_message" = "Puoi portare in questa casa i piatti e le settimane che hai già, oppure iniziare solo con quelli della casa.";
"household_join_choice_merge" = "Porta i miei piatti";
"household_join_choice_replace" = "Solo quelli della casa";
"household_members_section" = "Membri";
"household_member_you" = "tu";
"household_member_unnamed" = "Senza nome";
"household_invite_section" = "Invito";
"household_invite_footer" = "Condividi questo codice con chi vuoi che pianifichi con te.";
"household_invite_share" = "Entra nella mia casa MealMood con il codice %@ — https://mealmood.app";
"household_invite_expires" = "Scade il %@";
"household_invite_regenerate" = "Genera un nuovo codice";
"household_leave" = "Esci dalla casa";
"household_leave_confirm_title" = "Uscire dalla casa?";
"household_leave_confirm_message" = "Su questo dispositivo non vedrai né modificherai più i piatti e le settimane della casa.";
"household_leave_confirm_confirm" = "Esci";
"household_relaunch_needed" = "Chiudi e riapri MealMood per completare il cambio.";
"household_error_not_signed_in" = "Accedi prima di creare una casa o di unirti a una.";
"household_error_no_household" = "Questo dispositivo non appartiene a nessuna casa.";
"household_error_invalid_code" = "Quel codice non esiste.";
"household_error_expired_code" = "Quel codice è scaduto. Chiedine uno nuovo.";
@@ -478,40 +478,3 @@
"week_schedule_warning_confirm" = "Remover mesmo assim";
"toast_week_schedule_updated" = "Semana atualizada";
"toast_week_schedule_reset" = "Semana de volta às configurações gerais";
/* Hogar compartido (2.2) */
"household_title" = "Casa compartilhada";
"household_section" = "Casa";
"household_settings_footer" = "Planejem a semana juntos, cada um pela sua conta. Funciona também no Android.";
"household_intro" = "Uma casa permite que várias pessoas planejem a mesma semana, com os pratos e a lista de compras em comum.";
"household_sign_in_footer" = "Para compartilhar é preciso entrar. Seus pratos continuam sendo seus: ninguém fora da sua casa os vê.";
"household_create_section" = "Criar uma casa";
"household_create" = "Criar casa";
"household_name_placeholder" = "Nome da casa (ex.: Casa)";
"household_create_footer" = "Seus pratos, etiquetas e semanas atuais viram o ponto de partida da casa.";
"household_create_premium_footer" = "Criar uma casa é Premium. Quem recebe o convite não precisa pagar.";
"household_join_section" = "Entrar em uma casa";
"household_join" = "Entrar com o código";
"household_code_placeholder" = "Código de convite";
"household_join_footer" = "Peça o código a quem criou a casa. Ele expira em 7 dias.";
"household_join_choice_title" = "E os seus pratos?";
"household_join_choice_message" = "Você pode levar para esta casa os pratos e semanas que já tem, ou começar só com os da casa.";
"household_join_choice_merge" = "Levar meus pratos";
"household_join_choice_replace" = "Usar só os da casa";
"household_members_section" = "Integrantes";
"household_member_you" = "você";
"household_member_unnamed" = "Sem nome";
"household_invite_section" = "Convite";
"household_invite_footer" = "Compartilhe este código com quem vai planejar com você.";
"household_invite_share" = "Entre na minha casa do MealMood com o código %@ — https://mealmood.app";
"household_invite_expires" = "Expira em %@";
"household_invite_regenerate" = "Gerar um código novo";
"household_leave" = "Sair da casa";
"household_leave_confirm_title" = "Sair da casa?";
"household_leave_confirm_message" = "Você deixará de ver e editar os pratos e as semanas da casa neste dispositivo.";
"household_leave_confirm_confirm" = "Sair";
"household_relaunch_needed" = "Feche e abra o MealMood de novo para concluir a mudança.";
"household_error_not_signed_in" = "Entre na sua conta antes de criar ou entrar em uma casa.";
"household_error_no_household" = "Este dispositivo não pertence a nenhuma casa.";
"household_error_invalid_code" = "Esse código não existe.";
"household_error_expired_code" = "Esse código expirou. Peça um novo.";
@@ -1,141 +0,0 @@
import Foundation
import AuthenticationServices
import CryptoKit
import FirebaseAuth
/// Firebase Auth wrapper for the household feature. Sign in with Apple only for
/// now it is the one provider every iOS user already has, and Apple requires
/// offering it anyway once other social logins appear (Google arrives with the
/// Android PWA).
@MainActor
final class AuthService: ObservableObject {
static let shared = AuthService()
@Published private(set) var uid: String?
@Published private(set) var displayName: String?
/// Raw nonce of the sign-in in flight. Apple returns its SHA-256 inside the
/// identity token, and Firebase checks both match that is what stops a
/// stolen token from being replayed.
private var currentNonce: String?
private var stateListener: AuthStateDidChangeListenerHandle?
private init() {
let user = Auth.auth().currentUser
uid = user?.uid
displayName = user?.displayName
stateListener = Auth.auth().addStateDidChangeListener { [weak self] _, user in
Task { @MainActor in
self?.uid = user?.uid
self?.displayName = user?.displayName
}
}
}
var isSignedIn: Bool { uid != nil }
// MARK: - Sign in with Apple
/// Prepares the `ASAuthorizationAppleIDRequest` handed to
/// `SignInWithAppleButton`.
func prepare(request: ASAuthorizationAppleIDRequest) {
let nonce = Self.randomNonce()
currentNonce = nonce
request.requestedScopes = [.fullName, .email]
request.nonce = Self.sha256(nonce)
}
/// Exchanges Apple's credential for a Firebase session.
func completeSignInWithApple(_ result: Result<ASAuthorization, Error>) async throws {
switch result {
case .failure(let error):
// The user cancelling is not a failure worth surfacing.
if (error as? ASAuthorizationError)?.code == .canceled { return }
throw error
case .success(let authorization):
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
let tokenData = credential.identityToken,
let idToken = String(data: tokenData, encoding: .utf8) else {
throw AuthError.missingAppleToken
}
guard let nonce = currentNonce else {
throw AuthError.missingNonce
}
let firebaseCredential = OAuthProvider.appleCredential(
withIDToken: idToken,
rawNonce: nonce,
fullName: credential.fullName
)
let result = try await Auth.auth().signIn(with: firebaseCredential)
currentNonce = nil
// Apple only sends the name on the very first authorization, so it
// has to be persisted right away or it is lost forever.
if let fullName = credential.fullName {
let formatter = PersonNameComponentsFormatter()
let name = formatter.string(from: fullName).trimmingCharacters(in: .whitespaces)
if !name.isEmpty, result.user.displayName?.isEmpty ?? true {
let change = result.user.createProfileChangeRequest()
change.displayName = name
try? await change.commitChanges()
}
}
uid = result.user.uid
displayName = result.user.displayName
AnalyticsService.logEvent("household_signed_in")
}
}
func signOut() throws {
try Auth.auth().signOut()
uid = nil
displayName = nil
}
// MARK: - Nonce
private static func randomNonce(length: Int = 32) -> String {
let charset = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._")
var result = ""
var remaining = length
while remaining > 0 {
let randoms: [UInt8] = (0..<16).map { _ in
var random: UInt8 = 0
let status = SecRandomCopyBytes(kSecRandomDefault, 1, &random)
// A failing RNG must not silently degrade into a guessable nonce.
precondition(status == errSecSuccess, "SecRandomCopyBytes failed: \(status)")
return random
}
for random in randoms where remaining > 0 {
if random < charset.count {
result.append(charset[Int(random)])
remaining -= 1
}
}
}
return result
}
private static func sha256(_ input: String) -> String {
SHA256.hash(data: Data(input.utf8))
.map { String(format: "%02x", $0) }
.joined()
}
enum AuthError: LocalizedError {
case missingAppleToken
case missingNonce
var errorDescription: String? {
switch self {
case .missingAppleToken: return "Apple did not return an identity token."
case .missingNonce: return "Sign-in was not started by this app."
}
}
}
}
@@ -1,197 +0,0 @@
import Foundation
import CryptoKit
/// Translation between the SwiftData models and the Firestore documents of a
/// household, plus the document ids and the content hashing the sync uses to
/// tell "changed locally" from "unchanged".
///
/// `calendarEventId` and `photoData` are deliberately absent: the first is an
/// EventKit id that only means something on the device that created it, and the
/// second needs Firebase Storage (fase 2 in docs/household-sync.md).
enum HouseholdDocuments {
// MARK: - Document ids
/// Week documents are keyed by their Monday ("2026-09-14") instead of a
/// UUID so two members opening the same week write the same document
/// instead of creating two.
static func weekKey(for weekStart: Date) -> String {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: weekStart)
}
/// Same idea for slots: "5-dinner" is derived from the content, so the
/// Saturday dinner of a week is one document no matter who creates it.
static func slotId(dayOfWeek: Int, mealType: String) -> String {
"\(dayOfWeek)-\(mealType)"
}
// MARK: - Dish
static func fields(for dish: Dish) -> [String: Any] {
[
"name": dish.name,
"descriptionText": dish.descriptionText ?? NSNull(),
"tagIds": dish.tagIds.map(\.uuidString),
"ingredients": dish.ingredients,
"isPriority": dish.isPriority,
"fixedDayOfWeek": dish.fixedDayOfWeek ?? NSNull(),
"fixedMealType": dish.fixedMealType ?? NSNull(),
"createdAt": dish.createdAt.timeIntervalSince1970
]
}
static func apply(_ data: [String: Any], to dish: Dish) {
dish.name = data["name"] as? String ?? dish.name
dish.descriptionText = data["descriptionText"] as? String
dish.tagIds = (data["tagIds"] as? [String] ?? []).compactMap(UUID.init(uuidString:))
dish.ingredients = data["ingredients"] as? [String] ?? []
dish.isPriority = data["isPriority"] as? Bool ?? false
dish.fixedDayOfWeek = data["fixedDayOfWeek"] as? Int
dish.fixedMealType = data["fixedMealType"] as? String
if let created = data["createdAt"] as? Double {
dish.createdAt = Date(timeIntervalSince1970: created)
}
}
// MARK: - Tag
static func fields(for tag: Tag) -> [String: Any] {
[
"name": tag.name,
"nameEN": tag.nameEN,
"color": tag.color,
"maxPerWeek": tag.maxPerWeek ?? NSNull(),
"noConsecutive": tag.noConsecutive,
"noDuplicateInDay": tag.noDuplicateInDay,
"mealTypeRestriction": tag.mealTypeRestriction ?? NSNull(),
"dayRestriction": tag.dayRestriction ?? NSNull(),
"isDefault": tag.isDefault,
"sortOrder": tag.sortOrder
]
}
static func apply(_ data: [String: Any], to tag: Tag) {
tag.name = data["name"] as? String ?? tag.name
tag.nameEN = data["nameEN"] as? String ?? tag.nameEN
tag.color = data["color"] as? String ?? tag.color
tag.maxPerWeek = data["maxPerWeek"] as? Int
tag.noConsecutive = data["noConsecutive"] as? Bool ?? false
tag.noDuplicateInDay = data["noDuplicateInDay"] as? Bool ?? false
tag.mealTypeRestriction = data["mealTypeRestriction"] as? String
tag.dayRestriction = data["dayRestriction"] as? String
tag.isDefault = data["isDefault"] as? Bool ?? false
tag.sortOrder = data["sortOrder"] as? Int ?? 0
}
// MARK: - WeekPlan
static func fields(for plan: WeekPlan) -> [String: Any] {
[
"weekStartDate": plan.weekStartDate.timeIntervalSince1970,
"userRating": plan.userRating,
"includeWeekendsOverride": plan.includeWeekendsOverride ?? NSNull(),
"mealTypesOverrideRaw": plan.mealTypesOverrideRaw ?? NSNull()
]
}
static func apply(_ data: [String: Any], to plan: WeekPlan) {
plan.userRating = data["userRating"] as? Int ?? 0
plan.includeWeekendsOverride = data["includeWeekendsOverride"] as? Bool
plan.mealTypesOverrideRaw = data["mealTypesOverrideRaw"] as? String
}
// MARK: - MealSlot
static func fields(for slot: MealSlot) -> [String: Any] {
[
"dayOfWeek": slot.dayOfWeek,
"mealType": slot.mealType,
"dishId": slot.dishId?.uuidString ?? NSNull(),
"secondaryDishId": slot.secondaryDishId?.uuidString ?? NSNull(),
"isEatingOut": slot.isEatingOut,
"isSkipped": slot.isSkipped,
"isRuleOverridden": slot.isRuleOverridden,
"isRuleIgnored": slot.isRuleIgnored
]
}
static func apply(_ data: [String: Any], to slot: MealSlot) {
slot.dishId = (data["dishId"] as? String).flatMap(UUID.init(uuidString:))
slot.secondaryDishId = (data["secondaryDishId"] as? String).flatMap(UUID.init(uuidString:))
slot.isEatingOut = data["isEatingOut"] as? Bool ?? false
slot.isSkipped = data["isSkipped"] as? Bool ?? false
slot.isRuleOverridden = data["isRuleOverridden"] as? Bool ?? false
slot.isRuleIgnored = data["isRuleIgnored"] as? Bool ?? false
}
// MARK: - ShoppingItem
static func fields(for item: ShoppingItem) -> [String: Any] {
[
"weekStartDate": item.weekStartDate.timeIntervalSince1970,
"title": item.title,
"dishId": item.dishId?.uuidString ?? NSNull(),
"isChecked": item.isChecked,
"isDismissed": item.isDismissed,
"sortOrder": item.sortOrder,
"createdAt": item.createdAt.timeIntervalSince1970
]
}
static func apply(_ data: [String: Any], to item: ShoppingItem) {
item.title = data["title"] as? String ?? item.title
item.dishId = (data["dishId"] as? String).flatMap(UUID.init(uuidString:))
item.isChecked = data["isChecked"] as? Bool ?? false
item.isDismissed = data["isDismissed"] as? Bool ?? false
item.sortOrder = data["sortOrder"] as? Int ?? 0
if let created = data["createdAt"] as? Double {
item.createdAt = Date(timeIntervalSince1970: created)
}
}
// MARK: - Content hashing
/// Stable fingerprint of a document's payload. The sync keeps the last
/// synced fingerprint per id (the "shadow") and pushes only what differs,
/// which is what lets the app skip `updatedAt` bookkeeping in every view
/// that mutates a model.
static func fingerprint(_ fields: [String: Any]) -> String {
var parts: [String] = []
for key in fields.keys.sorted() {
parts.append("\(key)=\(describe(fields[key]))")
}
let joined = parts.joined(separator: "&")
return SHA256.hash(data: Data(joined.utf8))
.prefix(8)
.map { String(format: "%02x", $0) }
.joined()
}
/// `Bool` and `Int` both bridge to `NSNumber`, so a plain `as? Bool` cast
/// would make `true` and `1` hash the same. CFBoolean is what tells them
/// apart.
private static func describe(_ value: Any?) -> String {
switch value {
case nil, is NSNull:
return "~"
case let string as String:
return string
case let array as [String]:
return "[" + array.joined(separator: ",") + "]"
case let number as NSNumber:
if CFGetTypeID(number) == CFBooleanGetTypeID() {
return number.boolValue ? "true" : "false"
}
if CFNumberIsFloatType(number as CFNumber) {
return String(format: "%.3f", number.doubleValue)
}
return number.stringValue
default:
return String(describing: value)
}
}
}
@@ -1,40 +0,0 @@
import Foundation
/// Where the app's data lives this launch.
///
/// A device inside a household syncs through Firestore and must NOT also run
/// CloudKit: two mirrors writing the same objects fight each other (the same
/// reason the legacy iCloud KV sync is disabled whenever CloudKit is active).
/// The store configuration is fixed when the `ModelContainer` is built at
/// launch, so joining or leaving a household only takes effect after a relaunch
/// `needsRelaunch` tells the UI when to say so.
enum HouseholdRuntime {
private static let householdIdKey = "household_id"
private static let householdNameKey = "household_name"
/// Household this device belongs to, or nil when planning solo.
static var householdId: String? {
get { UserDefaults.standard.string(forKey: householdIdKey) }
set { UserDefaults.standard.set(newValue, forKey: householdIdKey) }
}
static var householdName: String? {
get { UserDefaults.standard.string(forKey: householdNameKey) }
set { UserDefaults.standard.set(newValue, forKey: householdNameKey) }
}
/// Whether the store built at launch is the household (local + Firestore)
/// one. Written once during app start, read-only afterwards.
nonisolated(unsafe) static var isHouseholdStore = false
/// True when the membership changed since the store was built, so the app
/// is running on the wrong store until it relaunches.
static var needsRelaunch: Bool {
(householdId != nil) != isHouseholdStore
}
static func clear() {
householdId = nil
householdName = nil
}
}
@@ -1,281 +0,0 @@
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")
}
}
}
}
@@ -1,478 +0,0 @@
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)])
}
}
}
-2
View File
@@ -249,7 +249,6 @@ 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)
}
}
@@ -795,7 +794,6 @@ 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
@@ -1,309 +0,0 @@
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,26 +227,6 @@ 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()) {