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 commit163fd60. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user