hogar compartido: colaboracion entre cuentas sobre Firestore
CloudKit sincroniza la base privada de un Apple ID: ni llega a Android ni deja que dos cuentas editen el mismo plan (CKShare sigue sin existir en SwiftData). El contenido de un hogar pasa por tanto a Firestore, y un dispositivo que entra en un hogar construye el store local sin CloudKit — dos espejos escribiendo los mismos objetos se pelean, que es justo lo que ya obligó a apagar el sync por iCloud KV. SwiftData sigue siendo el store local y el modo offline; HouseholdSyncService es lo unico que habla con la red. Detecta cambios comparando una huella del contenido de cada documento con la ultima sincronizada (el "shadow"), asi que no hace falta instrumentar con updatedAt las treinta vistas que mutan modelos. Los borrados van como tombstone: un borrado duro volveria desde cualquier miembro que estuviera sin conexion. Semanas y slots usan id derivado del contenido (2026-09-14, 5-dinner) para que dos miembros que abren la misma semana escriban el mismo documento en vez de crear dos, y para que los conflictos se resuelvan por slot y no por semana. Incluye reglas de seguridad (solo miembros; los codigos de invitacion se pueden leer por id pero no listar), pantalla de hogar en Ajustes con Sign in with Apple, invitacion por codigo de 6 caracteres sin vocales ni 0/O/1/I, y la eleccion al unirse entre llevarse los platos propios o adoptar los del hogar. Fuera de esta fase: fotos de platos (necesitan Storage) y el cliente Android. Refs #33 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246
This commit is contained in:
@@ -13,9 +13,11 @@
|
||||
0F91C8611D3BBE1FBD51D1E6 /* MealSlot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797A41D292142240FBDF9F2A /* MealSlot.swift */; };
|
||||
17E1054EE183486E7BE38C19 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6FC2153490D53B80DBDC034 /* WidgetDataStore.swift */; };
|
||||
19330C6ECE7A60110A3A679D /* TagListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB00DA865F6FE86952BFC3 /* TagListView.swift */; };
|
||||
1E99F323E0A8AC11DD733CB5 /* HouseholdSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79D8CD7D36201B9AB1A901E4 /* HouseholdSyncService.swift */; };
|
||||
203DDFF14C0688DEDB6EE83E /* Localization+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0164A33496A304E40685EA6D /* Localization+Helpers.swift */; };
|
||||
2124FD777418A4EEDF64BDC3 /* WeekReadyStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D954415A7AC73D41309D2BDF /* WeekReadyStepView.swift */; };
|
||||
233B9C1A5CA7E938318491C0 /* SocialShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1975C49FFC7D91AEB7932C97 /* SocialShareSheet.swift */; };
|
||||
241F22801CB94AE667BF184B /* HouseholdDocumentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470F235FE7288C1096B834C0 /* HouseholdDocumentsTests.swift */; };
|
||||
2519A55C6BDA686B6F85E79D /* View+MealCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC01EE9D78D980727A644CDF /* View+MealCard.swift */; };
|
||||
25B3C328E088A403CABAF20A /* PremiumSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 928E6D4F6D78AD1840245FB4 /* PremiumSyncService.swift */; };
|
||||
27D256A032E69F397E9552F3 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C2D1AAC0DB21EA5E2CCA38AD /* SwiftUI.framework */; };
|
||||
@@ -24,13 +26,16 @@
|
||||
30540EF74F2C97F741BA6A1A /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049AE68D5B577514ADD6BACB /* Tag.swift */; };
|
||||
31DFC035AE34F49FB35071A7 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C47DD7B8F4B1441780E611 /* HomeView.swift */; };
|
||||
37F5CEC4862C0AE2F3385ABA /* DishListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB3BD0E078644749E93B0D2B /* DishListView.swift */; };
|
||||
383442C406924A5858FA8016 /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 5FEFD3B35109089DC5ED252E /* FirebaseFirestore */; };
|
||||
3ACF9F301D6AC8A9A0B1AC3E /* MealMoodWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 2CF3C9FF75E9F24B32D54D5A /* MealMoodWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
3C5225918DA8DE77F5AC1AA0 /* MealMoodApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C20D9FC522FEBF7206CFD32 /* MealMoodApp.swift */; };
|
||||
3D83C33A78CACDB74989BE95 /* HouseholdService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3933965A7C5A18B7BE6DE43D /* HouseholdService.swift */; };
|
||||
4218F220B7412965A5001A88 /* CalendarStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76719058DD3A4177B2B4163E /* CalendarStepView.swift */; };
|
||||
443CC3997A9A4818B51066FB /* StatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8AD94FC874467D863D464C /* StatsView.swift */; };
|
||||
44E43D5F00D1B513F9BABE80 /* ShoppingListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C512046B1410E236A3F6286 /* ShoppingListService.swift */; };
|
||||
478AE18DA0F887D3D5164635 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C77C350D4A839B76A3D2EFE7 /* Foundation.framework */; };
|
||||
48F9D3308375F820B2B88D1B /* PremiumUpsellBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CFA001E1BFA916ABC4C44C1 /* PremiumUpsellBanner.swift */; };
|
||||
4D4724E1F36483B1D22273D8 /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 4260DAE0625E6C7D6DDDD153 /* FirebaseAuth */; };
|
||||
4D53B5101AE845D7C03A0EB4 /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = 192E7CF40FD34C3FF4DDFDA3 /* GoogleMobileAds */; };
|
||||
511DDB82FF4DD30018101418 /* DishDrawerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23957C735075B0DFB1DF51D7 /* DishDrawerView.swift */; };
|
||||
519AA84E7D3B9ED5FC215E4B /* MealMoodWatchWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A43C4A6B38F731B9FE6AACB /* MealMoodWatchWidget.swift */; };
|
||||
@@ -56,13 +61,16 @@
|
||||
7E163CDBF0832188EFA31C7D /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F46C32F97EAD52F8F3BC0B7 /* SettingsView.swift */; };
|
||||
861557A13918B99C421251E0 /* PremiumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324D77FEC4C8C371695F27CC /* PremiumView.swift */; };
|
||||
867A511F154A33E9DD7FE9BC /* CalendarService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A631C6D0CF7A0724ACF6A5 /* CalendarService.swift */; };
|
||||
87E31CDE1CCE4802FF7E2EC4 /* HouseholdView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20BF9DA2DD0717F66A3A574 /* HouseholdView.swift */; };
|
||||
87EB371FD3B6C241AC721C55 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 8E15828872FAEA71A7384099 /* Localizable.strings */; };
|
||||
880406FE20DDF465670CF6F3 /* HouseholdRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 444001767E7BAABE7560475B /* HouseholdRuntime.swift */; };
|
||||
8B12FAE3CC07AFB284094C61 /* WeekSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27337A07289450A1D494AC02 /* WeekSchedule.swift */; };
|
||||
8B4672C76B05BC1DFB5C979D /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5018BA4B3DC94CA7443CA9D1 /* TodayView.swift */; };
|
||||
8CEAC8993D33BFBBEB5319A8 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EE10285FEE9BE0684CC66A3 /* NotificationService.swift */; };
|
||||
8DB624B469178CFD8CD87672 /* OnboardingFlowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBD59AC26D5C6D138D67E35 /* OnboardingFlowUITests.swift */; };
|
||||
8F739A4496501009B8D829B3 /* Color+MealMood.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB771E85924AE086647A5113 /* Color+MealMood.swift */; };
|
||||
962BC821D22DE718E846BD64 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0614F890C8CA1620F8CF331 /* Foundation.framework */; };
|
||||
99B404198B12B5CCC9461AA5 /* HouseholdUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD59493349E71608861A6BA7 /* HouseholdUITests.swift */; };
|
||||
9BC60C30918C117608E43699 /* DefaultDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E00AB23342224B3407838B /* DefaultDataService.swift */; };
|
||||
9BE86765B8F0368AD5C86FC4 /* TagViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9BFFE7A4BC6961815D2334 /* TagViewModel.swift */; };
|
||||
9D40600B9AC21F0FF64B77F9 /* StoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 103374A7D91B4F884278DA72 /* StoreManager.swift */; };
|
||||
@@ -84,6 +92,7 @@
|
||||
B4D6F8012C3E5F7A91B3D5F7 /* FeedbackStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */; };
|
||||
B8D7CA62789429C2F693E4F6 /* MealMoodLogoMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC64110469AA91D5F28C423 /* MealMoodLogoMark.swift */; };
|
||||
B8FDF31D3BD7DD7220CE21A3 /* ShoppingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC93C653B5540D7205BF294 /* ShoppingItem.swift */; };
|
||||
BA965DE97C273AC2AA7AD92F /* HouseholdDocuments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 288CAA2EC068786039E2D719 /* HouseholdDocuments.swift */; };
|
||||
BE908B23F64F33D594AB8D82 /* DateHelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD33453B7DF86EDBAEB80F1 /* DateHelpersTests.swift */; };
|
||||
BF89CD5E8CA0DFFC224CF94D /* WatchWeekPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67CBDF2D2972139881AA59B1 /* WatchWeekPayload.swift */; };
|
||||
C14BDE4C3EF6AF75F378D892 /* PrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA97AD958C922D8AFF73314 /* PrimaryButton.swift */; };
|
||||
@@ -109,6 +118,7 @@
|
||||
E3BD64E6D32ED7052F174923 /* TagSelectorSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CF1C9CC9BDB3B20B1834EB /* TagSelectorSheet.swift */; };
|
||||
E5D9C4B783A14E2B9F4D2E68 /* PaywallStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F61A8C92B47D5938C61E9A75 /* PaywallStepView.swift */; };
|
||||
E6A57F29C02AE138D705BAF7 /* AppIconPlaceholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00F2EE5A756C183B6D505403 /* AppIconPlaceholder.swift */; };
|
||||
E79AA07F325241DDD03C9557 /* AuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 566384A35602C58EE062DB06 /* AuthService.swift */; };
|
||||
E8528D9162976DBEDCEF1822 /* MealMoodWatchWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 6CFA7D73F403353774761AD1 /* MealMoodWatchWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
EA4EC6244EEBE6D568BCA5E5 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B3944B4AD876455057EB2265 /* Assets.xcassets */; };
|
||||
F65AD80426AF8DA12FBD661C /* WeekendsStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035C6C0B728EB0EA80394CDD /* WeekendsStepView.swift */; };
|
||||
@@ -208,18 +218,23 @@
|
||||
212FF80B5B9E9E5243CA79D5 /* MealMoodUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MealMoodUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
23957C735075B0DFB1DF51D7 /* DishDrawerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DishDrawerView.swift; sourceTree = "<group>"; };
|
||||
27337A07289450A1D494AC02 /* WeekSchedule.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WeekSchedule.swift; sourceTree = "<group>"; };
|
||||
288CAA2EC068786039E2D719 /* HouseholdDocuments.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HouseholdDocuments.swift; sourceTree = "<group>"; };
|
||||
2C20D9FC522FEBF7206CFD32 /* MealMoodApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealMoodApp.swift; sourceTree = "<group>"; };
|
||||
2C5CD466A50C339519544347 /* TagRulesEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagRulesEditView.swift; sourceTree = "<group>"; };
|
||||
2CF3C9FF75E9F24B32D54D5A /* MealMoodWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MealMoodWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
2E113A29DEC0551C41AB7EBB /* WeekPlanShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekPlanShareView.swift; sourceTree = "<group>"; };
|
||||
324D77FEC4C8C371695F27CC /* PremiumView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumView.swift; sourceTree = "<group>"; };
|
||||
3933965A7C5A18B7BE6DE43D /* HouseholdService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HouseholdService.swift; sourceTree = "<group>"; };
|
||||
3CBD59AC26D5C6D138D67E35 /* OnboardingFlowUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OnboardingFlowUITests.swift; sourceTree = "<group>"; };
|
||||
3FC93C653B5540D7205BF294 /* ShoppingItem.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShoppingItem.swift; sourceTree = "<group>"; };
|
||||
444001767E7BAABE7560475B /* HouseholdRuntime.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HouseholdRuntime.swift; sourceTree = "<group>"; };
|
||||
449577ECE261177D4A1C1572 /* WatchSyncService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSyncService.swift; sourceTree = "<group>"; };
|
||||
470F235FE7288C1096B834C0 /* HouseholdDocumentsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HouseholdDocumentsTests.swift; sourceTree = "<group>"; };
|
||||
4EDEE086FD20D16DE507B247 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
||||
5018BA4B3DC94CA7443CA9D1 /* TodayView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; };
|
||||
50E1C12C3A693D7C41E9F80B /* TagPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagPill.swift; sourceTree = "<group>"; };
|
||||
5187023D83BBC5E8D304CE3A /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
|
||||
566384A35602C58EE062DB06 /* AuthService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = "<group>"; };
|
||||
574CB4BDE993DA0CEC251ABD /* MealMoodWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MealMoodWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5BA71D570D9882C3AFAFDC48 /* ShoppingListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ShoppingListView.swift; path = Shopping/ShoppingListView.swift; sourceTree = "<group>"; };
|
||||
5C512046B1410E236A3F6286 /* ShoppingListService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShoppingListService.swift; sourceTree = "<group>"; };
|
||||
@@ -242,6 +257,7 @@
|
||||
7603187713D390BD4D35770C /* AutocompleteEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteEngineTests.swift; sourceTree = "<group>"; };
|
||||
76719058DD3A4177B2B4163E /* CalendarStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarStepView.swift; sourceTree = "<group>"; };
|
||||
797A41D292142240FBDF9F2A /* MealSlot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealSlot.swift; sourceTree = "<group>"; };
|
||||
79D8CD7D36201B9AB1A901E4 /* HouseholdSyncService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HouseholdSyncService.swift; sourceTree = "<group>"; };
|
||||
7DE70CD2A01FCE5370A46DD3 /* Date+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Helpers.swift"; sourceTree = "<group>"; };
|
||||
812303C94C8C1D2FADF3CB37 /* AdMobConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdMobConfig.swift; sourceTree = "<group>"; };
|
||||
8411FF2737FDEF48170F9634 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
@@ -258,10 +274,12 @@
|
||||
A1B2C3D4E5F60718293A4B02 /* IngredientParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IngredientParser.swift; sourceTree = "<group>"; };
|
||||
A1B2C3D4E5F60718293A4B04 /* SpeechDictationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SpeechDictationService.swift; sourceTree = "<group>"; };
|
||||
A1C3CBAFE191AD6643F861B7 /* DeduplicationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeduplicationService.swift; sourceTree = "<group>"; };
|
||||
A20BF9DA2DD0717F66A3A574 /* HouseholdView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HouseholdView.swift; sourceTree = "<group>"; };
|
||||
A23267BAA116E1214870416C /* WeekPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekPlan.swift; sourceTree = "<group>"; };
|
||||
A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackStore.swift; sourceTree = "<group>"; };
|
||||
A6FC2153490D53B80DBDC034 /* WidgetDataStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = "<group>"; };
|
||||
AB771E85924AE086647A5113 /* Color+MealMood.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+MealMood.swift"; sourceTree = "<group>"; };
|
||||
AD59493349E71608861A6BA7 /* HouseholdUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HouseholdUITests.swift; sourceTree = "<group>"; };
|
||||
B042BB2F81973D854E84BABD /* MealMoodWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MealMoodWidget.swift; sourceTree = "<group>"; };
|
||||
B0A631C6D0CF7A0724ACF6A5 /* CalendarService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarService.swift; sourceTree = "<group>"; };
|
||||
B3369706BF941E09F88243F1 /* MealMoodWidget.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = MealMoodWidget.entitlements; sourceTree = "<group>"; };
|
||||
@@ -344,6 +362,8 @@
|
||||
FB0011111111111111111101 /* FirebaseAnalytics in Frameworks */,
|
||||
FB0011111111111111111108 /* FirebaseCrashlytics in Frameworks */,
|
||||
C3CB4ACC213DAAD25E610C56 /* WidgetKit.framework in Frameworks */,
|
||||
4D4724E1F36483B1D22273D8 /* FirebaseAuth in Frameworks */,
|
||||
383442C406924A5858FA8016 /* FirebaseFirestore in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -411,6 +431,19 @@
|
||||
path = Premium;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3FAA36B72672FA8DFD76A98E /* Household */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
444001767E7BAABE7560475B /* HouseholdRuntime.swift */,
|
||||
566384A35602C58EE062DB06 /* AuthService.swift */,
|
||||
3933965A7C5A18B7BE6DE43D /* HouseholdService.swift */,
|
||||
288CAA2EC068786039E2D719 /* HouseholdDocuments.swift */,
|
||||
79D8CD7D36201B9AB1A901E4 /* HouseholdSyncService.swift */,
|
||||
);
|
||||
name = Household;
|
||||
path = Household;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4B4ACE1D0F2968B284A1FA7E /* Configuration */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -423,6 +456,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0F46C32F97EAD52F8F3BC0B7 /* SettingsView.swift */,
|
||||
A20BF9DA2DD0717F66A3A574 /* HouseholdView.swift */,
|
||||
);
|
||||
path = Settings;
|
||||
sourceTree = "<group>";
|
||||
@@ -507,6 +541,7 @@
|
||||
62E2F6B2694CEC97AE3C1B13 /* PremiumSyncServiceTests.swift */,
|
||||
69C0BDFEBA8B692530CB6B5E /* SpeechDictationServiceTests.swift */,
|
||||
91C2BCF9D7A1458E045DA053 /* WeekScheduleTests.swift */,
|
||||
470F235FE7288C1096B834C0 /* HouseholdDocumentsTests.swift */,
|
||||
);
|
||||
path = MealMoodTests;
|
||||
sourceTree = "<group>";
|
||||
@@ -596,6 +631,7 @@
|
||||
children = (
|
||||
3CBD59AC26D5C6D138D67E35 /* OnboardingFlowUITests.swift */,
|
||||
6FCDE0D7D1609EC0DB3DD134 /* WeekScheduleUITests.swift */,
|
||||
AD59493349E71608861A6BA7 /* HouseholdUITests.swift */,
|
||||
);
|
||||
name = MealMoodUITests;
|
||||
path = MealMoodUITests;
|
||||
@@ -693,6 +729,7 @@
|
||||
A1C3CBAFE191AD6643F861B7 /* DeduplicationService.swift */,
|
||||
67CBDF2D2972139881AA59B1 /* WatchWeekPayload.swift */,
|
||||
449577ECE261177D4A1C1572 /* WatchSyncService.swift */,
|
||||
3FAA36B72672FA8DFD76A98E /* Household */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
@@ -790,6 +827,8 @@
|
||||
192E7CF40FD34C3FF4DDFDA3 /* GoogleMobileAds */,
|
||||
FB0011111111111111111105 /* FirebaseAnalytics */,
|
||||
FB001111111111111111110B /* FirebaseCrashlytics */,
|
||||
4260DAE0625E6C7D6DDDD153 /* FirebaseAuth */,
|
||||
5FEFD3B35109089DC5ED252E /* FirebaseFirestore */,
|
||||
);
|
||||
productName = MealMood;
|
||||
productReference = C7B13F4D4D362E50CA65A0C1 /* MealMood.app */;
|
||||
@@ -930,6 +969,7 @@
|
||||
files = (
|
||||
8DB624B469178CFD8CD87672 /* OnboardingFlowUITests.swift in Sources */,
|
||||
CC96D3416E044B750C99B625 /* WeekScheduleUITests.swift in Sources */,
|
||||
99B404198B12B5CCC9461AA5 /* HouseholdUITests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -973,6 +1013,7 @@
|
||||
2A58E7C96AF7C14EBFE1C65C /* PremiumSyncServiceTests.swift in Sources */,
|
||||
64CC61B20F0AF602A6CC1114 /* SpeechDictationServiceTests.swift in Sources */,
|
||||
A569F5FED382E8EABC471416 /* WeekScheduleTests.swift in Sources */,
|
||||
241F22801CB94AE667BF184B /* HouseholdDocumentsTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -1055,6 +1096,12 @@
|
||||
C6DBE6B5B4BE002A234CBCCA /* WatchSyncService.swift in Sources */,
|
||||
8B12FAE3CC07AFB284094C61 /* WeekSchedule.swift in Sources */,
|
||||
57F913037D2D381DFA1FB7DE /* WeekScheduleSheet.swift in Sources */,
|
||||
880406FE20DDF465670CF6F3 /* HouseholdRuntime.swift in Sources */,
|
||||
E79AA07F325241DDD03C9557 /* AuthService.swift in Sources */,
|
||||
3D83C33A78CACDB74989BE95 /* HouseholdService.swift in Sources */,
|
||||
BA965DE97C273AC2AA7AD92F /* HouseholdDocuments.swift in Sources */,
|
||||
1E99F323E0A8AC11DD733CB5 /* HouseholdSyncService.swift in Sources */,
|
||||
87E31CDE1CCE4802FF7E2EC4 /* HouseholdView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -1586,6 +1633,16 @@
|
||||
package = BE33D996A0D3FB7D5E000A7F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */;
|
||||
productName = GoogleMobileAds;
|
||||
};
|
||||
4260DAE0625E6C7D6DDDD153 /* FirebaseAuth */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = FB0011111111111111111104 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
|
||||
productName = FirebaseAuth;
|
||||
};
|
||||
5FEFD3B35109089DC5ED252E /* FirebaseFirestore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = FB0011111111111111111104 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
|
||||
productName = FirebaseFirestore;
|
||||
};
|
||||
FB0011111111111111111105 /* FirebaseAnalytics */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = FB0011111111111111111104 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
|
||||
|
||||
@@ -33,6 +33,11 @@ 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,17 +49,25 @@ 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.).
|
||||
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)")
|
||||
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 configuration = ModelConfiguration(cloudKitDatabase: .none)
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
</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,3 +478,40 @@
|
||||
"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,3 +478,40 @@
|
||||
"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,3 +478,40 @@
|
||||
"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,3 +478,40 @@
|
||||
"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,3 +478,40 @@
|
||||
"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,3 +478,40 @@
|
||||
"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.";
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
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,6 +249,7 @@ struct HomeView: View {
|
||||
guard let settings = settings,
|
||||
let plan = ensureCurrentWeekPlanExists(settings: settings) else { return }
|
||||
viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context)
|
||||
HouseholdSyncService.shared.focus(weekStart: viewModel.currentWeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,6 +795,7 @@ struct HomeView: View {
|
||||
language: settings.languageEnum.resolved()
|
||||
)
|
||||
wasWeekComplete = isWeekComplete(plan: plan)
|
||||
HouseholdSyncService.shared.focus(weekStart: viewModel.currentWeekStart)
|
||||
evaluatePostOnboardingPromptsIfNeeded(plan: plan, settings: settings)
|
||||
evaluateWidgetPromo()
|
||||
if settings.isPremium {
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import AuthenticationServices
|
||||
|
||||
/// Household screen: sign in, create or join a household, see who is in it and
|
||||
/// leave. The content sync itself runs in `HouseholdSyncService`.
|
||||
struct HouseholdView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Query private var allSettings: [AppSettings]
|
||||
|
||||
@StateObject private var auth = AuthService.shared
|
||||
@StateObject private var households = HouseholdService.shared
|
||||
@StateObject private var sync = HouseholdSyncService.shared
|
||||
|
||||
@State private var householdName: String = ""
|
||||
@State private var joinCode: String = ""
|
||||
@State private var isWorking = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showJoinChoice = false
|
||||
@State private var showLeaveConfirm = false
|
||||
@State private var showPaywall = false
|
||||
|
||||
private var settings: AppSettings? { allSettings.first }
|
||||
private var isPremium: Bool { settings?.isPremium ?? false }
|
||||
private var isInHousehold: Bool { HouseholdRuntime.householdId != nil }
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if HouseholdRuntime.needsRelaunch {
|
||||
Section {
|
||||
Label("household_relaunch_needed", systemImage: "arrow.clockwise.circle.fill")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodWarning)
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
|
||||
if !auth.isSignedIn {
|
||||
signInSection
|
||||
} else if isInHousehold {
|
||||
householdSection
|
||||
membersSection
|
||||
inviteSection
|
||||
leaveSection
|
||||
} else {
|
||||
createSection
|
||||
joinSection
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
Section {
|
||||
Text(errorMessage)
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodWarning)
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.mealMoodBackground.ignoresSafeArea())
|
||||
.tint(.mealMoodCoral)
|
||||
.navigationTitle("household_title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.disabled(isWorking)
|
||||
.onAppear {
|
||||
households.start()
|
||||
AnalyticsService.logScreenView("Household")
|
||||
}
|
||||
.sheet(isPresented: $showPaywall) {
|
||||
if let settings {
|
||||
NavigationStack {
|
||||
PremiumView(settings: settings, source: "household")
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog("household_join_choice_title", isPresented: $showJoinChoice, titleVisibility: .visible) {
|
||||
Button("household_join_choice_merge") { join(bringingLocalContent: true) }
|
||||
Button("household_join_choice_replace", role: .destructive) { join(bringingLocalContent: false) }
|
||||
Button("reset_cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("household_join_choice_message")
|
||||
}
|
||||
.alert("household_leave_confirm_title", isPresented: $showLeaveConfirm) {
|
||||
Button("reset_cancel", role: .cancel) {}
|
||||
Button("household_leave_confirm_confirm", role: .destructive) { leave() }
|
||||
} message: {
|
||||
Text("household_leave_confirm_message")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var signInSection: some View {
|
||||
Section {
|
||||
Text("household_intro")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
|
||||
SignInWithAppleButton(.signIn) { request in
|
||||
auth.prepare(request: request)
|
||||
} onCompletion: { result in
|
||||
Task {
|
||||
do {
|
||||
try await auth.completeSignInWithApple(result)
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
.signInWithAppleButtonStyle(.black)
|
||||
.frame(height: 46)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
} footer: {
|
||||
Text("household_sign_in_footer")
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
|
||||
private var createSection: some View {
|
||||
Section {
|
||||
TextField("household_name_placeholder", text: $householdName)
|
||||
Button {
|
||||
guard isPremium else {
|
||||
showPaywall = true
|
||||
AnalyticsService.logEvent("paywall_viewed", parameters: ["source": "household_create"])
|
||||
return
|
||||
}
|
||||
create()
|
||||
} label: {
|
||||
HStack {
|
||||
Label("household_create", systemImage: "house.fill")
|
||||
if !isPremium {
|
||||
Spacer()
|
||||
Image(systemName: "star.circle.fill").foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(householdName.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
} header: {
|
||||
Text("household_create_section")
|
||||
} footer: {
|
||||
if isPremium {
|
||||
Text("household_create_footer")
|
||||
} else {
|
||||
Text("household_create_premium_footer")
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
|
||||
private var joinSection: some View {
|
||||
Section {
|
||||
TextField("household_code_placeholder", text: $joinCode)
|
||||
.textInputAutocapitalization(.characters)
|
||||
.autocorrectionDisabled()
|
||||
Button {
|
||||
showJoinChoice = true
|
||||
} label: {
|
||||
Label("household_join", systemImage: "person.badge.plus")
|
||||
}
|
||||
.disabled(!HouseholdService.isPlausibleCode(joinCode))
|
||||
} header: {
|
||||
Text("household_join_section")
|
||||
} footer: {
|
||||
Text("household_join_footer")
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
|
||||
private var householdSection: some View {
|
||||
Section {
|
||||
HStack {
|
||||
Label(households.household?.name ?? HouseholdRuntime.householdName ?? "", systemImage: "house.fill")
|
||||
Spacer()
|
||||
if sync.isSyncing {
|
||||
ProgressView()
|
||||
} else if let lastSyncedAt = sync.lastSyncedAt {
|
||||
Text(lastSyncedAt.formatted(date: .omitted, time: .shortened))
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
}
|
||||
if let syncError = sync.lastError {
|
||||
Text(syncError)
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodWarning)
|
||||
}
|
||||
} header: {
|
||||
Text("household_section")
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
|
||||
private var membersSection: some View {
|
||||
Section {
|
||||
ForEach(households.members) { member in
|
||||
HStack {
|
||||
Label(
|
||||
member.displayName.isEmpty ? String(localized: "household_member_unnamed") : member.displayName,
|
||||
systemImage: member.role == "owner" ? "crown.fill" : "person.fill"
|
||||
)
|
||||
Spacer()
|
||||
if member.id == auth.uid {
|
||||
Text("household_member_you")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("household_members_section")
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
|
||||
private var inviteSection: some View {
|
||||
Section {
|
||||
if let code = households.household?.inviteCode {
|
||||
HStack {
|
||||
Text(code)
|
||||
.font(.system(.title3, design: .monospaced))
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Spacer()
|
||||
ShareLink(item: String(format: String(localized: "household_invite_share"), code)) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
if let expiry = households.household?.inviteExpiresAt {
|
||||
Text(String(format: String(localized: "household_invite_expires"),
|
||||
expiry.formatted(date: .abbreviated, time: .omitted)))
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
}
|
||||
Button {
|
||||
regenerateCode()
|
||||
} label: {
|
||||
Label("household_invite_regenerate", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
} header: {
|
||||
Text("household_invite_section")
|
||||
} footer: {
|
||||
Text("household_invite_footer")
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
|
||||
private var leaveSection: some View {
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
showLeaveConfirm = true
|
||||
} label: {
|
||||
Label("household_leave", systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func create() {
|
||||
run {
|
||||
let name = householdName.trimmingCharacters(in: .whitespaces)
|
||||
_ = try await households.createHousehold(name: name, displayName: auth.displayName ?? "")
|
||||
// Everything already planned on this device becomes the household's
|
||||
// starting point.
|
||||
await sync.seedNewHousehold()
|
||||
}
|
||||
}
|
||||
|
||||
private func join(bringingLocalContent: Bool) {
|
||||
run {
|
||||
_ = try await households.join(code: joinCode, displayName: auth.displayName ?? "")
|
||||
if !bringingLocalContent {
|
||||
sync.replaceLocalContent(context: context)
|
||||
}
|
||||
joinCode = ""
|
||||
}
|
||||
}
|
||||
|
||||
private func leave() {
|
||||
run {
|
||||
try await households.leaveHousehold()
|
||||
}
|
||||
}
|
||||
|
||||
private func regenerateCode() {
|
||||
run {
|
||||
try await households.regenerateInviteCode()
|
||||
}
|
||||
}
|
||||
|
||||
private func run(_ operation: @escaping () async throws -> Void) {
|
||||
isWorking = true
|
||||
errorMessage = nil
|
||||
Task {
|
||||
do {
|
||||
try await operation()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
CrashlyticsService.record(error, context: "household_action")
|
||||
}
|
||||
isWorking = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,6 +227,26 @@ struct SettingsView: View {
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
.task { await refreshNotificationStatus() }
|
||||
|
||||
// Household section (2.2): shared planning across accounts.
|
||||
Section {
|
||||
NavigationLink(destination: HouseholdView()) {
|
||||
HStack {
|
||||
Text("household_title")
|
||||
Spacer()
|
||||
if let name = HouseholdRuntime.householdName, HouseholdRuntime.householdId != nil {
|
||||
Text(name)
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Label("household_section", systemImage: "person.2")
|
||||
} footer: {
|
||||
Text("household_settings_footer")
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
|
||||
// Tags section
|
||||
Section {
|
||||
NavigationLink(destination: TagListView()) {
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import XCTest
|
||||
import SwiftData
|
||||
@testable import MealMood
|
||||
|
||||
/// The pure half of the household sync: document ids, fingerprints and the
|
||||
/// model ↔ Firestore mapping. Anything touching the network stays out.
|
||||
final class HouseholdDocumentsTests: XCTestCase {
|
||||
|
||||
/// Held for the duration of the test: releasing the container tears the
|
||||
/// store off the coordinator and the next SwiftData call traps.
|
||||
private var container: ModelContainer!
|
||||
private var context: ModelContext!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
container = try ModelContainer(
|
||||
for: AppSettings.self, Dish.self, Tag.self, WeekPlan.self, MealSlot.self, ShoppingItem.self,
|
||||
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
)
|
||||
context = ModelContext(container)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
context = nil
|
||||
container = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Document ids
|
||||
|
||||
func testWeekKeyIsTheMondayDate() {
|
||||
let monday = Calendar(identifier: .gregorian)
|
||||
.date(from: DateComponents(year: 2026, month: 9, day: 14))!
|
||||
|
||||
XCTAssertEqual(HouseholdDocuments.weekKey(for: monday), "2026-09-14")
|
||||
}
|
||||
|
||||
func testWeekKeyIsStableAcrossLocales() {
|
||||
let monday = Calendar(identifier: .gregorian)
|
||||
.date(from: DateComponents(year: 2026, month: 1, day: 5))!
|
||||
|
||||
// Two members with different phone languages must write the same
|
||||
// document, not one each.
|
||||
XCTAssertEqual(HouseholdDocuments.weekKey(for: monday), "2026-01-05")
|
||||
}
|
||||
|
||||
func testSlotIdIsDerivedFromContent() {
|
||||
XCTAssertEqual(HouseholdDocuments.slotId(dayOfWeek: 5, mealType: "dinner"), "5-dinner")
|
||||
XCTAssertEqual(HouseholdDocuments.slotId(dayOfWeek: 0, mealType: "breakfast"), "0-breakfast")
|
||||
}
|
||||
|
||||
// MARK: - Fingerprints
|
||||
|
||||
func testFingerprintIgnoresKeyOrder() {
|
||||
let a: [String: Any] = ["name": "Tortilla", "isPriority": true, "sortOrder": 3]
|
||||
let b: [String: Any] = ["sortOrder": 3, "name": "Tortilla", "isPriority": true]
|
||||
|
||||
XCTAssertEqual(HouseholdDocuments.fingerprint(a), HouseholdDocuments.fingerprint(b))
|
||||
}
|
||||
|
||||
func testFingerprintChangesWithContent() {
|
||||
let dish = Dish(name: "Tortilla")
|
||||
context.insert(dish)
|
||||
let before = HouseholdDocuments.fingerprint(HouseholdDocuments.fields(for: dish))
|
||||
|
||||
dish.name = "Tortilla de patatas"
|
||||
let after = HouseholdDocuments.fingerprint(HouseholdDocuments.fields(for: dish))
|
||||
|
||||
XCTAssertNotEqual(before, after, "an edited dish must be detected as a local change")
|
||||
}
|
||||
|
||||
func testFingerprintDistinguishesBoolFromInt() {
|
||||
let asBool: [String: Any] = ["value": true]
|
||||
let asInt: [String: Any] = ["value": 1]
|
||||
|
||||
XCTAssertNotEqual(HouseholdDocuments.fingerprint(asBool), HouseholdDocuments.fingerprint(asInt))
|
||||
}
|
||||
|
||||
// MARK: - Round trips
|
||||
|
||||
func testDishSurvivesARoundTrip() {
|
||||
let tagId = UUID()
|
||||
let original = Dish(
|
||||
name: "Salmón al horno",
|
||||
descriptionText: "con limón",
|
||||
tagIds: [tagId],
|
||||
isPriority: true,
|
||||
ingredients: ["salmón", "limón"],
|
||||
fixedDayOfWeek: 4,
|
||||
fixedMealType: "dinner"
|
||||
)
|
||||
context.insert(original)
|
||||
|
||||
let copy = Dish(id: original.id, name: "")
|
||||
context.insert(copy)
|
||||
HouseholdDocuments.apply(HouseholdDocuments.fields(for: original), to: copy)
|
||||
|
||||
XCTAssertEqual(copy.name, "Salmón al horno")
|
||||
XCTAssertEqual(copy.descriptionText, "con limón")
|
||||
XCTAssertEqual(copy.tagIds, [tagId])
|
||||
XCTAssertEqual(copy.ingredients, ["salmón", "limón"])
|
||||
XCTAssertTrue(copy.isPriority)
|
||||
XCTAssertEqual(copy.fixedDayOfWeek, 4)
|
||||
XCTAssertEqual(copy.fixedMealType, "dinner")
|
||||
XCTAssertEqual(
|
||||
HouseholdDocuments.fingerprint(HouseholdDocuments.fields(for: original)),
|
||||
HouseholdDocuments.fingerprint(HouseholdDocuments.fields(for: copy)),
|
||||
"a round trip must not look like a change to push"
|
||||
)
|
||||
}
|
||||
|
||||
func testTagRulesSurviveARoundTrip() {
|
||||
let original = Tag(
|
||||
name: "Pescado", nameEN: "Fish", color: "#4FC3F7",
|
||||
maxPerWeek: 2, noConsecutive: true, noDuplicateInDay: true,
|
||||
mealTypeRestriction: "dinner", dayRestriction: "weekdays",
|
||||
isDefault: false, sortOrder: 7
|
||||
)
|
||||
context.insert(original)
|
||||
|
||||
let copy = Tag(id: original.id, name: "", color: "")
|
||||
context.insert(copy)
|
||||
HouseholdDocuments.apply(HouseholdDocuments.fields(for: original), to: copy)
|
||||
|
||||
XCTAssertEqual(copy.nameEN, "Fish")
|
||||
XCTAssertEqual(copy.maxPerWeek, 2)
|
||||
XCTAssertTrue(copy.noConsecutive)
|
||||
XCTAssertEqual(copy.mealTypeRestriction, "dinner")
|
||||
XCTAssertEqual(copy.dayRestriction, "weekdays", "day rules must travel or they'd silently reset")
|
||||
XCTAssertEqual(copy.sortOrder, 7)
|
||||
}
|
||||
|
||||
func testSlotSurvivesARoundTripWithoutTheCalendarEventId() {
|
||||
let original = MealSlot(dayOfWeek: 2, mealType: "lunch")
|
||||
original.dishId = UUID()
|
||||
original.secondaryDishId = UUID()
|
||||
original.isSkipped = true
|
||||
original.calendarEventId = "local-eventkit-id"
|
||||
context.insert(original)
|
||||
|
||||
let copy = MealSlot(dayOfWeek: 2, mealType: "lunch")
|
||||
context.insert(copy)
|
||||
HouseholdDocuments.apply(HouseholdDocuments.fields(for: original), to: copy)
|
||||
|
||||
XCTAssertEqual(copy.dishId, original.dishId)
|
||||
XCTAssertEqual(copy.secondaryDishId, original.secondaryDishId)
|
||||
XCTAssertTrue(copy.isSkipped)
|
||||
// The EventKit id only means something on the device that created it.
|
||||
XCTAssertNil(copy.calendarEventId)
|
||||
XCTAssertNil(HouseholdDocuments.fields(for: original)["calendarEventId"])
|
||||
}
|
||||
|
||||
func testWeekPlanCarriesItsScheduleOverride() {
|
||||
let plan = WeekPlan(weekStartDate: Date().startOfWeek())
|
||||
plan.includeWeekendsOverride = true
|
||||
plan.mealTypesOverrideRaw = "breakfast,dinner"
|
||||
plan.userRating = 1
|
||||
context.insert(plan)
|
||||
|
||||
let copy = WeekPlan(weekStartDate: plan.weekStartDate)
|
||||
context.insert(copy)
|
||||
HouseholdDocuments.apply(HouseholdDocuments.fields(for: plan), to: copy)
|
||||
|
||||
XCTAssertEqual(copy.includeWeekendsOverride, true)
|
||||
XCTAssertEqual(copy.mealTypesOverrideRaw, "breakfast,dinner")
|
||||
XCTAssertEqual(copy.userRating, 1)
|
||||
}
|
||||
|
||||
func testShoppingItemSurvivesARoundTrip() {
|
||||
let item = ShoppingItem(weekStartDate: Date().startOfWeek(), title: "Leche", isChecked: true, sortOrder: 4)
|
||||
context.insert(item)
|
||||
|
||||
let copy = ShoppingItem(id: item.id, weekStartDate: item.weekStartDate, title: "")
|
||||
context.insert(copy)
|
||||
HouseholdDocuments.apply(HouseholdDocuments.fields(for: item), to: copy)
|
||||
|
||||
XCTAssertEqual(copy.title, "Leche")
|
||||
XCTAssertTrue(copy.isChecked)
|
||||
XCTAssertEqual(copy.sortOrder, 4)
|
||||
}
|
||||
|
||||
// MARK: - Invite codes
|
||||
|
||||
func testInviteCodesUseAnUnambiguousAlphabet() {
|
||||
for _ in 0..<200 {
|
||||
let code = HouseholdService.makeInviteCode()
|
||||
XCTAssertEqual(code.count, 6)
|
||||
// No 0/O/1/I and no vowels: codes get read out loud and retyped.
|
||||
XCTAssertFalse(code.contains(where: { "01IOAEU".contains($0) }), "ambiguous character in \(code)")
|
||||
}
|
||||
}
|
||||
|
||||
func testCodeNormalizationAcceptsHowPeopleTypeIt() {
|
||||
let code = HouseholdService.makeInviteCode()
|
||||
let messy = code.lowercased().split(separator: "").joined()
|
||||
let spaced = code.map(String.init).joined(separator: " ")
|
||||
let dashed = code.prefix(3) + "-" + code.suffix(3)
|
||||
|
||||
XCTAssertEqual(HouseholdService.normalize(messy), code)
|
||||
XCTAssertEqual(HouseholdService.normalize(spaced), code)
|
||||
XCTAssertEqual(HouseholdService.normalize(String(dashed)), code)
|
||||
XCTAssertTrue(HouseholdService.isPlausibleCode(spaced))
|
||||
}
|
||||
|
||||
func testShortOrEmptyCodesAreRejectedBeforeHittingTheNetwork() {
|
||||
XCTAssertFalse(HouseholdService.isPlausibleCode(""))
|
||||
XCTAssertFalse(HouseholdService.isPlausibleCode("BCD"))
|
||||
XCTAssertFalse(HouseholdService.isPlausibleCode("AEIOU"), "vowels aren't part of the alphabet")
|
||||
}
|
||||
|
||||
// MARK: - Shadow
|
||||
|
||||
func testShadowTracksWhatWasSynced() {
|
||||
var shadow = SyncShadow()
|
||||
shadow.set(path: "dishes/abc", fingerprint: "1234")
|
||||
|
||||
XCTAssertEqual(shadow.fingerprint(for: "dishes/abc"), "1234")
|
||||
XCTAssertNil(shadow.fingerprint(for: "dishes/other"))
|
||||
|
||||
shadow.remove(path: "dishes/abc")
|
||||
XCTAssertNil(shadow.fingerprint(for: "dishes/abc"))
|
||||
|
||||
shadow.set(path: "tags/x", fingerprint: "ffff")
|
||||
shadow.reset()
|
||||
XCTAssertTrue(shadow.paths.isEmpty)
|
||||
}
|
||||
|
||||
func testShadowSurvivesARelaunch() {
|
||||
let householdId = "test-\(UUID().uuidString)"
|
||||
var shadow = SyncShadow()
|
||||
shadow.set(path: "dishes/abc", fingerprint: "1234")
|
||||
shadow.save(householdId: householdId)
|
||||
|
||||
var restored = SyncShadow()
|
||||
restored.load(householdId: householdId)
|
||||
|
||||
XCTAssertEqual(restored.fingerprint(for: "dishes/abc"), "1234",
|
||||
"without this the whole store would re-upload on every launch")
|
||||
UserDefaults.standard.removeObject(forKey: "household_shadow_\(householdId)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import XCTest
|
||||
|
||||
/// The household screen has to be reachable from Settings and offer sign-in
|
||||
/// before anything else — none of which a unit test can see.
|
||||
final class HouseholdUITests: XCTestCase {
|
||||
|
||||
func testHouseholdScreenIsReachableFromSettings() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US", "UITEST_DISABLE_ANALYTICS"]
|
||||
app.launch()
|
||||
|
||||
if !app.otherElements["home_root"].waitForExistence(timeout: 5) {
|
||||
for id in ["onboarding_cta_welcome", "onboarding_cta_meal_windows", "onboarding_cta_weekends",
|
||||
"onboarding_cta_calendar", "onboarding_cta_first_dishes",
|
||||
"onboarding_cta_week_ready_skip", "onboarding_cta_paywall_skip"] {
|
||||
let button = app.buttons[id]
|
||||
if button.waitForExistence(timeout: 10) { button.tap() }
|
||||
}
|
||||
XCTAssertTrue(app.otherElements["home_root"].waitForExistence(timeout: 15), "Home never appeared")
|
||||
}
|
||||
|
||||
app.buttons.matching(identifier: "gearshape").firstMatch.tap()
|
||||
let settingsRow = app.buttons["Shared household"].firstMatch
|
||||
XCTAssertTrue(settingsRow.waitForExistence(timeout: 10), "Household row missing from Settings")
|
||||
settingsRow.tap()
|
||||
|
||||
XCTAssertTrue(app.navigationBars["Shared household"].waitForExistence(timeout: 5),
|
||||
"Household screen did not open")
|
||||
// Signed out: sign-in comes first, creating or joining is not offered yet.
|
||||
XCTAssertTrue(app.buttons["Sign in with Apple"].waitForExistence(timeout: 5), "Apple sign-in button missing")
|
||||
XCTAssertFalse(app.buttons["Create household"].exists, "Create must wait until there is an account")
|
||||
|
||||
let screenshot = app.screenshot()
|
||||
let attachment = XCTAttachment(screenshot: screenshot)
|
||||
attachment.name = "household-signed-out"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
try? screenshot.pngRepresentation.write(to: URL(fileURLWithPath: "/tmp/mealmood-household.png"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
# Hogar compartido (2.2) — diseño
|
||||
|
||||
Colaboración entre cuentas distintas y acceso desde Android. Issue #33.
|
||||
|
||||
## Por qué no CloudKit
|
||||
|
||||
El store es hoy `NSPersistentCloudKitContainer` (SwiftData con
|
||||
`cloudKitDatabase: .automatic`), que sincroniza la base de datos **privada** de
|
||||
un Apple ID entre los dispositivos de esa persona. Dos cosas lo descartan aquí:
|
||||
|
||||
- No existe en Android.
|
||||
- Compartir entre cuentas exige `CKShare`, que SwiftData no expone (ya se
|
||||
intentó en la 2.0 y se aparcó a la 2.1).
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
iOS / iPadOS │ SwiftData (store local) │ ← toda la UI sigue leyendo de aquí (@Query)
|
||||
└────────────┬─────────────┘
|
||||
│ HouseholdSyncService (espejo bidireccional)
|
||||
┌────────────┴─────────────┐
|
||||
│ Firestore (el hogar) │ ← fuente de verdad cuando hay hogar
|
||||
└────────────┬─────────────┘
|
||||
│
|
||||
Android / web │ PWA (fase 2), lee y escribe el mismo hogar
|
||||
```
|
||||
|
||||
SwiftData **no** se sustituye: sigue siendo el store local y el modo offline.
|
||||
Firestore es la fuente de verdad del contenido del hogar, y el servicio de sync
|
||||
mantiene ambos lados alineados.
|
||||
|
||||
### CloudKit y Firestore no conviven
|
||||
|
||||
Dos sincronizaciones sobre los mismos objetos se pelean — ya pasó con el sync
|
||||
por iCloud KV, y por eso existe `CloudSyncRuntime.isCloudKitActive`. Regla:
|
||||
|
||||
- Sin hogar → como hoy: CloudKit activo, Firestore inactivo.
|
||||
- Con hogar → contenedor local (`cloudKitDatabase: .none`) y Firestore manda.
|
||||
|
||||
El contenedor se decide en el arranque, así que al entrar o salir de un hogar
|
||||
la app pide relanzarse. Es un corte visible, pero la alternativa (dos orígenes
|
||||
escribiendo los mismos objetos, con ecos entre ellos) es una fuente de pérdida
|
||||
de datos.
|
||||
|
||||
## Modelo en Firestore
|
||||
|
||||
```
|
||||
users/{uid}
|
||||
householdId, displayName, updatedAt
|
||||
|
||||
invites/{code} ← resuelve código → hogar sin leer todos los hogares
|
||||
householdId, createdBy, expiresAt
|
||||
|
||||
households/{householdId}
|
||||
name, createdAt, createdBy, memberIds: [uid], ownerId
|
||||
members/{uid} displayName, role: owner|member, joinedAt
|
||||
dishes/{uuid} name, descriptionText, tagIds[], ingredients[], isPriority,
|
||||
fixedDayOfWeek, fixedMealType, updatedAt, updatedBy, deletedAt?
|
||||
tags/{uuid} name, nameEN, color, reglas…, updatedAt, updatedBy, deletedAt?
|
||||
weekPlans/{yyyy-MM-dd} weekStartDate, userRating, includeWeekendsOverride,
|
||||
mealTypesOverrideRaw, updatedAt, updatedBy
|
||||
slots/{day}-{meal} dishId, secondaryDishId, isEatingOut, isSkipped,
|
||||
isRuleOverridden, isRuleIgnored, updatedAt, updatedBy
|
||||
shoppingItems/{uuid} weekStartDate, title, dishId, isChecked, isDismissed,
|
||||
sortOrder, updatedAt, updatedBy, deletedAt?
|
||||
```
|
||||
|
||||
### IDs deterministas donde importa
|
||||
|
||||
Semanas y slots usan id derivado del contenido (`2026-09-14`, `5-dinner`) en vez
|
||||
de UUID. Si dos miembros abren la misma semana a la vez, ambos escriben el mismo
|
||||
documento en lugar de crear dos. Es la misma clase de duplicado que costó el
|
||||
crash de arranque de la 2.0, pero resuelta en el origen.
|
||||
|
||||
Platos, etiquetas y líneas de la compra conservan su UUID: los crea una persona
|
||||
concreta y no hay id natural.
|
||||
|
||||
## Conflictos
|
||||
|
||||
Last-write-wins por **documento**, con `updatedAt` de servidor
|
||||
(`FieldValue.serverTimestamp()`) y `updatedBy` para poder depurar. La
|
||||
granularidad importa: el documento es el slot, no la semana, así que dos
|
||||
personas pueden rellenar martes y jueves a la vez sin pisarse.
|
||||
|
||||
Los borrados son tombstones (`deletedAt`), no borrados duros: sin ellos, un
|
||||
dispositivo offline que no vio el borrado recrearía el plato al volver.
|
||||
|
||||
## Alcance de la fase 1
|
||||
|
||||
Dentro:
|
||||
|
||||
- Cuentas (Sign in with Apple + Google) y hogar con invitación por código.
|
||||
- Sync de platos, etiquetas, semanas, slots y lista de la compra.
|
||||
- Desactivación de CloudKit al entrar en un hogar.
|
||||
|
||||
Fuera, a propósito:
|
||||
|
||||
- **Fotos de platos**: `photoData` va a `@Attribute(.externalStorage)` y un
|
||||
documento de Firestore tope a 1 MB. Van a Firebase Storage en fase 2.
|
||||
- **Ajustes personales** (`AppSettings`): no se comparten. Idioma, horas de
|
||||
calendario o estilo de export son de cada persona. Lo que sí es del hogar
|
||||
(días y comidas planificadas) viaja en el propio `weekPlan`.
|
||||
- **PWA de Android**: fase 2, sobre este mismo modelo.
|
||||
|
||||
## Premium
|
||||
|
||||
Crear un hogar requiere premium. Los invitados entran sin premium — es el gancho
|
||||
del plan familiar, y quien invita ya paga. El resto de features premium siguen
|
||||
bloqueadas para el invitado.
|
||||
|
||||
`isPremium` sigue viniendo solo de StoreKit y nunca se sincroniza, igual que hoy.
|
||||
@@ -0,0 +1,96 @@
|
||||
rules_version = '2';
|
||||
|
||||
// Hogar compartido de MealMood (issue #33). Ver docs/household-sync.md.
|
||||
//
|
||||
// Regla de oro: el contenido de un hogar solo lo ven y lo escriben sus
|
||||
// miembros. Lo único legible desde fuera es un código de invitación concreto,
|
||||
// y solo por id — nunca listando la colección.
|
||||
service cloud.firestore {
|
||||
match /databases/{database}/documents {
|
||||
|
||||
function signedIn() {
|
||||
return request.auth != null;
|
||||
}
|
||||
|
||||
function uid() {
|
||||
return request.auth.uid;
|
||||
}
|
||||
|
||||
function isMember(householdId) {
|
||||
return signedIn()
|
||||
&& uid() in get(/databases/$(database)/documents/households/$(householdId)).data.memberIds;
|
||||
}
|
||||
|
||||
// Perfil mínimo: a qué hogar pertenece cada persona y su nombre visible.
|
||||
match /users/{userId} {
|
||||
allow read, write: if signedIn() && uid() == userId;
|
||||
}
|
||||
|
||||
// Códigos de invitación. `get` abierto a cualquier persona autenticada —
|
||||
// es la única forma de resolver un código antes de ser miembro. `list`
|
||||
// queda prohibido para que nadie pueda barrer códigos ajenos.
|
||||
match /invites/{code} {
|
||||
allow get: if signedIn();
|
||||
allow list: if false;
|
||||
allow create, delete: if signedIn()
|
||||
&& request.resource.data.createdBy == uid();
|
||||
allow update: if false;
|
||||
}
|
||||
|
||||
match /households/{householdId} {
|
||||
allow get: if isMember(householdId);
|
||||
allow list: if false;
|
||||
|
||||
// Al crear, la persona queda como dueña y único miembro.
|
||||
allow create: if signedIn()
|
||||
&& request.resource.data.ownerId == uid()
|
||||
&& request.resource.data.memberIds == [uid()];
|
||||
|
||||
// Dos actualizaciones legítimas:
|
||||
// - un miembro cambia datos del hogar (nombre, código de invitación),
|
||||
// - alguien de fuera se añade a sí mismo con un código válido.
|
||||
allow update: if isMember(householdId) || joiningWithValidInvite(householdId);
|
||||
|
||||
allow delete: if signedIn()
|
||||
&& resource.data.ownerId == uid();
|
||||
|
||||
function joiningWithValidInvite(householdId) {
|
||||
let code = request.resource.data.lastJoinCode;
|
||||
let invitePath = /databases/$(database)/documents/invites/$(code);
|
||||
return signedIn()
|
||||
&& exists(invitePath)
|
||||
&& get(invitePath).data.householdId == householdId
|
||||
&& get(invitePath).data.expiresAt > request.time
|
||||
// Solo puede añadirse a sí misma, y sin tocar a nadie más.
|
||||
&& request.resource.data.memberIds == resource.data.memberIds.concat([uid()]);
|
||||
}
|
||||
|
||||
match /members/{memberId} {
|
||||
allow read: if isMember(householdId);
|
||||
// Cada persona escribe su propia ficha; nadie edita la de otro.
|
||||
allow write: if signedIn() && uid() == memberId;
|
||||
}
|
||||
|
||||
// Contenido del hogar: platos, etiquetas, semanas, slots y compra.
|
||||
match /dishes/{dishId} {
|
||||
allow read, write: if isMember(householdId);
|
||||
}
|
||||
|
||||
match /tags/{tagId} {
|
||||
allow read, write: if isMember(householdId);
|
||||
}
|
||||
|
||||
match /weekPlans/{weekKey} {
|
||||
allow read, write: if isMember(householdId);
|
||||
|
||||
match /slots/{slotId} {
|
||||
allow read, write: if isMember(householdId);
|
||||
}
|
||||
}
|
||||
|
||||
match /shoppingItems/{itemId} {
|
||||
allow read, write: if isMember(householdId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user