hogar compartido: colaboracion entre cuentas sobre Firestore

CloudKit sincroniza la base privada de un Apple ID: ni llega a Android ni deja
que dos cuentas editen el mismo plan (CKShare sigue sin existir en SwiftData).
El contenido de un hogar pasa por tanto a Firestore, y un dispositivo que entra
en un hogar construye el store local sin CloudKit — dos espejos escribiendo los
mismos objetos se pelean, que es justo lo que ya obligó a apagar el sync por
iCloud KV.

SwiftData sigue siendo el store local y el modo offline; HouseholdSyncService es
lo unico que habla con la red. Detecta cambios comparando una huella del
contenido de cada documento con la ultima sincronizada (el "shadow"), asi que no
hace falta instrumentar con updatedAt las treinta vistas que mutan modelos. Los
borrados van como tombstone: un borrado duro volveria desde cualquier miembro
que estuviera sin conexion.

Semanas y slots usan id derivado del contenido (2026-09-14, 5-dinner) para que
dos miembros que abren la misma semana escriban el mismo documento en vez de
crear dos, y para que los conflictos se resuelvan por slot y no por semana.

Incluye reglas de seguridad (solo miembros; los codigos de invitacion se pueden
leer por id pero no listar), pantalla de hogar en Ajustes con Sign in with Apple,
invitacion por codigo de 6 caracteres sin vocales ni 0/O/1/I, y la eleccion al
unirse entre llevarse los platos propios o adoptar los del hogar.

Fuera de esta fase: fotos de platos (necesitan Storage) y el cliente Android.

Refs #33

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246
This commit is contained in:
alexandrev-tibco
2026-09-12 13:00:38 +02:00
parent 66128e7a5c
commit 163fd6026a
22 changed files with 2261 additions and 8 deletions
+57
View File
@@ -13,9 +13,11 @@
0F91C8611D3BBE1FBD51D1E6 /* MealSlot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797A41D292142240FBDF9F2A /* MealSlot.swift */; }; 0F91C8611D3BBE1FBD51D1E6 /* MealSlot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797A41D292142240FBDF9F2A /* MealSlot.swift */; };
17E1054EE183486E7BE38C19 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6FC2153490D53B80DBDC034 /* WidgetDataStore.swift */; }; 17E1054EE183486E7BE38C19 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6FC2153490D53B80DBDC034 /* WidgetDataStore.swift */; };
19330C6ECE7A60110A3A679D /* TagListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB00DA865F6FE86952BFC3 /* TagListView.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 */; }; 203DDFF14C0688DEDB6EE83E /* Localization+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0164A33496A304E40685EA6D /* Localization+Helpers.swift */; };
2124FD777418A4EEDF64BDC3 /* WeekReadyStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D954415A7AC73D41309D2BDF /* WeekReadyStepView.swift */; }; 2124FD777418A4EEDF64BDC3 /* WeekReadyStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D954415A7AC73D41309D2BDF /* WeekReadyStepView.swift */; };
233B9C1A5CA7E938318491C0 /* SocialShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1975C49FFC7D91AEB7932C97 /* SocialShareSheet.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 */; }; 2519A55C6BDA686B6F85E79D /* View+MealCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC01EE9D78D980727A644CDF /* View+MealCard.swift */; };
25B3C328E088A403CABAF20A /* PremiumSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 928E6D4F6D78AD1840245FB4 /* PremiumSyncService.swift */; }; 25B3C328E088A403CABAF20A /* PremiumSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 928E6D4F6D78AD1840245FB4 /* PremiumSyncService.swift */; };
27D256A032E69F397E9552F3 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C2D1AAC0DB21EA5E2CCA38AD /* SwiftUI.framework */; }; 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 */; }; 30540EF74F2C97F741BA6A1A /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049AE68D5B577514ADD6BACB /* Tag.swift */; };
31DFC035AE34F49FB35071A7 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C47DD7B8F4B1441780E611 /* HomeView.swift */; }; 31DFC035AE34F49FB35071A7 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C47DD7B8F4B1441780E611 /* HomeView.swift */; };
37F5CEC4862C0AE2F3385ABA /* DishListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB3BD0E078644749E93B0D2B /* DishListView.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, ); }; }; 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 */; }; 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 */; }; 4218F220B7412965A5001A88 /* CalendarStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76719058DD3A4177B2B4163E /* CalendarStepView.swift */; };
443CC3997A9A4818B51066FB /* StatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8AD94FC874467D863D464C /* StatsView.swift */; }; 443CC3997A9A4818B51066FB /* StatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8AD94FC874467D863D464C /* StatsView.swift */; };
44E43D5F00D1B513F9BABE80 /* ShoppingListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C512046B1410E236A3F6286 /* ShoppingListService.swift */; }; 44E43D5F00D1B513F9BABE80 /* ShoppingListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C512046B1410E236A3F6286 /* ShoppingListService.swift */; };
478AE18DA0F887D3D5164635 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C77C350D4A839B76A3D2EFE7 /* Foundation.framework */; }; 478AE18DA0F887D3D5164635 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C77C350D4A839B76A3D2EFE7 /* Foundation.framework */; };
48F9D3308375F820B2B88D1B /* PremiumUpsellBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CFA001E1BFA916ABC4C44C1 /* PremiumUpsellBanner.swift */; }; 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 */; }; 4D53B5101AE845D7C03A0EB4 /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = 192E7CF40FD34C3FF4DDFDA3 /* GoogleMobileAds */; };
511DDB82FF4DD30018101418 /* DishDrawerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23957C735075B0DFB1DF51D7 /* DishDrawerView.swift */; }; 511DDB82FF4DD30018101418 /* DishDrawerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23957C735075B0DFB1DF51D7 /* DishDrawerView.swift */; };
519AA84E7D3B9ED5FC215E4B /* MealMoodWatchWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A43C4A6B38F731B9FE6AACB /* MealMoodWatchWidget.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 */; }; 7E163CDBF0832188EFA31C7D /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F46C32F97EAD52F8F3BC0B7 /* SettingsView.swift */; };
861557A13918B99C421251E0 /* PremiumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324D77FEC4C8C371695F27CC /* PremiumView.swift */; }; 861557A13918B99C421251E0 /* PremiumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324D77FEC4C8C371695F27CC /* PremiumView.swift */; };
867A511F154A33E9DD7FE9BC /* CalendarService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A631C6D0CF7A0724ACF6A5 /* CalendarService.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 */; }; 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 */; }; 8B12FAE3CC07AFB284094C61 /* WeekSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27337A07289450A1D494AC02 /* WeekSchedule.swift */; };
8B4672C76B05BC1DFB5C979D /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5018BA4B3DC94CA7443CA9D1 /* TodayView.swift */; }; 8B4672C76B05BC1DFB5C979D /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5018BA4B3DC94CA7443CA9D1 /* TodayView.swift */; };
8CEAC8993D33BFBBEB5319A8 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EE10285FEE9BE0684CC66A3 /* NotificationService.swift */; }; 8CEAC8993D33BFBBEB5319A8 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EE10285FEE9BE0684CC66A3 /* NotificationService.swift */; };
8DB624B469178CFD8CD87672 /* OnboardingFlowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBD59AC26D5C6D138D67E35 /* OnboardingFlowUITests.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 */; }; 8F739A4496501009B8D829B3 /* Color+MealMood.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB771E85924AE086647A5113 /* Color+MealMood.swift */; };
962BC821D22DE718E846BD64 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0614F890C8CA1620F8CF331 /* Foundation.framework */; }; 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 */; }; 9BC60C30918C117608E43699 /* DefaultDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E00AB23342224B3407838B /* DefaultDataService.swift */; };
9BE86765B8F0368AD5C86FC4 /* TagViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9BFFE7A4BC6961815D2334 /* TagViewModel.swift */; }; 9BE86765B8F0368AD5C86FC4 /* TagViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9BFFE7A4BC6961815D2334 /* TagViewModel.swift */; };
9D40600B9AC21F0FF64B77F9 /* StoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 103374A7D91B4F884278DA72 /* StoreManager.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 */; }; B4D6F8012C3E5F7A91B3D5F7 /* FeedbackStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */; };
B8D7CA62789429C2F693E4F6 /* MealMoodLogoMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC64110469AA91D5F28C423 /* MealMoodLogoMark.swift */; }; B8D7CA62789429C2F693E4F6 /* MealMoodLogoMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC64110469AA91D5F28C423 /* MealMoodLogoMark.swift */; };
B8FDF31D3BD7DD7220CE21A3 /* ShoppingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC93C653B5540D7205BF294 /* ShoppingItem.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 */; }; BE908B23F64F33D594AB8D82 /* DateHelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD33453B7DF86EDBAEB80F1 /* DateHelpersTests.swift */; };
BF89CD5E8CA0DFFC224CF94D /* WatchWeekPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67CBDF2D2972139881AA59B1 /* WatchWeekPayload.swift */; }; BF89CD5E8CA0DFFC224CF94D /* WatchWeekPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67CBDF2D2972139881AA59B1 /* WatchWeekPayload.swift */; };
C14BDE4C3EF6AF75F378D892 /* PrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA97AD958C922D8AFF73314 /* PrimaryButton.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 */; }; E3BD64E6D32ED7052F174923 /* TagSelectorSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CF1C9CC9BDB3B20B1834EB /* TagSelectorSheet.swift */; };
E5D9C4B783A14E2B9F4D2E68 /* PaywallStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F61A8C92B47D5938C61E9A75 /* PaywallStepView.swift */; }; E5D9C4B783A14E2B9F4D2E68 /* PaywallStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F61A8C92B47D5938C61E9A75 /* PaywallStepView.swift */; };
E6A57F29C02AE138D705BAF7 /* AppIconPlaceholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00F2EE5A756C183B6D505403 /* AppIconPlaceholder.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, ); }; }; 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 */; }; EA4EC6244EEBE6D568BCA5E5 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B3944B4AD876455057EB2265 /* Assets.xcassets */; };
F65AD80426AF8DA12FBD661C /* WeekendsStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035C6C0B728EB0EA80394CDD /* WeekendsStepView.swift */; }; 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; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; B3369706BF941E09F88243F1 /* MealMoodWidget.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = MealMoodWidget.entitlements; sourceTree = "<group>"; };
@@ -344,6 +362,8 @@
FB0011111111111111111101 /* FirebaseAnalytics in Frameworks */, FB0011111111111111111101 /* FirebaseAnalytics in Frameworks */,
FB0011111111111111111108 /* FirebaseCrashlytics in Frameworks */, FB0011111111111111111108 /* FirebaseCrashlytics in Frameworks */,
C3CB4ACC213DAAD25E610C56 /* WidgetKit.framework in Frameworks */, C3CB4ACC213DAAD25E610C56 /* WidgetKit.framework in Frameworks */,
4D4724E1F36483B1D22273D8 /* FirebaseAuth in Frameworks */,
383442C406924A5858FA8016 /* FirebaseFirestore in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -411,6 +431,19 @@
path = Premium; path = Premium;
sourceTree = "<group>"; 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 */ = { 4B4ACE1D0F2968B284A1FA7E /* Configuration */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -423,6 +456,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0F46C32F97EAD52F8F3BC0B7 /* SettingsView.swift */, 0F46C32F97EAD52F8F3BC0B7 /* SettingsView.swift */,
A20BF9DA2DD0717F66A3A574 /* HouseholdView.swift */,
); );
path = Settings; path = Settings;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -507,6 +541,7 @@
62E2F6B2694CEC97AE3C1B13 /* PremiumSyncServiceTests.swift */, 62E2F6B2694CEC97AE3C1B13 /* PremiumSyncServiceTests.swift */,
69C0BDFEBA8B692530CB6B5E /* SpeechDictationServiceTests.swift */, 69C0BDFEBA8B692530CB6B5E /* SpeechDictationServiceTests.swift */,
91C2BCF9D7A1458E045DA053 /* WeekScheduleTests.swift */, 91C2BCF9D7A1458E045DA053 /* WeekScheduleTests.swift */,
470F235FE7288C1096B834C0 /* HouseholdDocumentsTests.swift */,
); );
path = MealMoodTests; path = MealMoodTests;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -596,6 +631,7 @@
children = ( children = (
3CBD59AC26D5C6D138D67E35 /* OnboardingFlowUITests.swift */, 3CBD59AC26D5C6D138D67E35 /* OnboardingFlowUITests.swift */,
6FCDE0D7D1609EC0DB3DD134 /* WeekScheduleUITests.swift */, 6FCDE0D7D1609EC0DB3DD134 /* WeekScheduleUITests.swift */,
AD59493349E71608861A6BA7 /* HouseholdUITests.swift */,
); );
name = MealMoodUITests; name = MealMoodUITests;
path = MealMoodUITests; path = MealMoodUITests;
@@ -693,6 +729,7 @@
A1C3CBAFE191AD6643F861B7 /* DeduplicationService.swift */, A1C3CBAFE191AD6643F861B7 /* DeduplicationService.swift */,
67CBDF2D2972139881AA59B1 /* WatchWeekPayload.swift */, 67CBDF2D2972139881AA59B1 /* WatchWeekPayload.swift */,
449577ECE261177D4A1C1572 /* WatchSyncService.swift */, 449577ECE261177D4A1C1572 /* WatchSyncService.swift */,
3FAA36B72672FA8DFD76A98E /* Household */,
); );
path = Services; path = Services;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -790,6 +827,8 @@
192E7CF40FD34C3FF4DDFDA3 /* GoogleMobileAds */, 192E7CF40FD34C3FF4DDFDA3 /* GoogleMobileAds */,
FB0011111111111111111105 /* FirebaseAnalytics */, FB0011111111111111111105 /* FirebaseAnalytics */,
FB001111111111111111110B /* FirebaseCrashlytics */, FB001111111111111111110B /* FirebaseCrashlytics */,
4260DAE0625E6C7D6DDDD153 /* FirebaseAuth */,
5FEFD3B35109089DC5ED252E /* FirebaseFirestore */,
); );
productName = MealMood; productName = MealMood;
productReference = C7B13F4D4D362E50CA65A0C1 /* MealMood.app */; productReference = C7B13F4D4D362E50CA65A0C1 /* MealMood.app */;
@@ -930,6 +969,7 @@
files = ( files = (
8DB624B469178CFD8CD87672 /* OnboardingFlowUITests.swift in Sources */, 8DB624B469178CFD8CD87672 /* OnboardingFlowUITests.swift in Sources */,
CC96D3416E044B750C99B625 /* WeekScheduleUITests.swift in Sources */, CC96D3416E044B750C99B625 /* WeekScheduleUITests.swift in Sources */,
99B404198B12B5CCC9461AA5 /* HouseholdUITests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -973,6 +1013,7 @@
2A58E7C96AF7C14EBFE1C65C /* PremiumSyncServiceTests.swift in Sources */, 2A58E7C96AF7C14EBFE1C65C /* PremiumSyncServiceTests.swift in Sources */,
64CC61B20F0AF602A6CC1114 /* SpeechDictationServiceTests.swift in Sources */, 64CC61B20F0AF602A6CC1114 /* SpeechDictationServiceTests.swift in Sources */,
A569F5FED382E8EABC471416 /* WeekScheduleTests.swift in Sources */, A569F5FED382E8EABC471416 /* WeekScheduleTests.swift in Sources */,
241F22801CB94AE667BF184B /* HouseholdDocumentsTests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -1055,6 +1096,12 @@
C6DBE6B5B4BE002A234CBCCA /* WatchSyncService.swift in Sources */, C6DBE6B5B4BE002A234CBCCA /* WatchSyncService.swift in Sources */,
8B12FAE3CC07AFB284094C61 /* WeekSchedule.swift in Sources */, 8B12FAE3CC07AFB284094C61 /* WeekSchedule.swift in Sources */,
57F913037D2D381DFA1FB7DE /* WeekScheduleSheet.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; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -1586,6 +1633,16 @@
package = BE33D996A0D3FB7D5E000A7F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */; package = BE33D996A0D3FB7D5E000A7F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */;
productName = GoogleMobileAds; 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 */ = { FB0011111111111111111105 /* FirebaseAnalytics */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = FB0011111111111111111104 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; package = FB0011111111111111111104 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
+5
View File
@@ -33,6 +33,11 @@ struct ContentView: View {
// Activate WatchConnectivity as early as possible so the first widget // Activate WatchConnectivity as early as possible so the first widget
// refresh doesn't race the session activation. // refresh doesn't race the session activation.
.onAppear { WatchSyncService.shared.activate() } .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 // This is the most reliable way to catch a purchase even if the app was
// interrupted during the payment flow. // interrupted during the payment flow.
.task { .task {
+16 -8
View File
@@ -49,17 +49,25 @@ struct MealMoodApp: App {
ShoppingItem.self 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). // 2.0: CloudKit private-database sync (multi-device, same Apple ID).
// Falls back to the local-only store when CloudKit isn't available // Falls back to the local-only store when CloudKit isn't available
// (signed-out iCloud, missing entitlement in dev builds, etc.). // (signed-out iCloud, missing entitlement in dev builds, etc.).
let cloudConfiguration = ModelConfiguration(cloudKitDatabase: .automatic) if !HouseholdRuntime.isHouseholdStore {
do { let cloudConfiguration = ModelConfiguration(cloudKitDatabase: .automatic)
let container = try ModelContainer(for: schema, configurations: [cloudConfiguration]) do {
CloudSyncRuntime.isCloudKitActive = true let container = try ModelContainer(for: schema, configurations: [cloudConfiguration])
return container CloudSyncRuntime.isCloudKitActive = true
} catch { return container
CrashlyticsService.record(error, context: "cloudkit_container") } catch {
print("CloudKit container unavailable, using local store: \(error)") CrashlyticsService.record(error, context: "cloudkit_container")
print("CloudKit container unavailable, using local store: \(error)")
}
} }
let configuration = ModelConfiguration(cloudKitDatabase: .none) let configuration = ModelConfiguration(cloudKitDatabase: .none)
+4
View File
@@ -14,6 +14,10 @@
</array> </array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key> <key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string> <string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.com.alexandrevazquez.mealmood</string> <string>group.com.alexandrevazquez.mealmood</string>
@@ -478,3 +478,40 @@
"week_schedule_warning_confirm" = "Trotzdem entfernen"; "week_schedule_warning_confirm" = "Trotzdem entfernen";
"toast_week_schedule_updated" = "Woche aktualisiert"; "toast_week_schedule_updated" = "Woche aktualisiert";
"toast_week_schedule_reset" = "Woche folgt wieder deinen Einstellungen"; "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"; "week_schedule_warning_confirm" = "Remove anyway";
"toast_week_schedule_updated" = "Week updated"; "toast_week_schedule_updated" = "Week updated";
"toast_week_schedule_reset" = "Week back to your general settings"; "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"; "week_schedule_warning_confirm" = "Quitar igualmente";
"toast_week_schedule_updated" = "Semana actualizada"; "toast_week_schedule_updated" = "Semana actualizada";
"toast_week_schedule_reset" = "Semana con tu configuración general"; "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"; "week_schedule_warning_confirm" = "Retirer quand même";
"toast_week_schedule_updated" = "Semaine mise à jour"; "toast_week_schedule_updated" = "Semaine mise à jour";
"toast_week_schedule_reset" = "Semaine revenue à vos réglages généraux"; "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"; "week_schedule_warning_confirm" = "Rimuovi comunque";
"toast_week_schedule_updated" = "Settimana aggiornata"; "toast_week_schedule_updated" = "Settimana aggiornata";
"toast_week_schedule_reset" = "Settimana tornata alle impostazioni generali"; "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"; "week_schedule_warning_confirm" = "Remover mesmo assim";
"toast_week_schedule_updated" = "Semana atualizada"; "toast_week_schedule_updated" = "Semana atualizada";
"toast_week_schedule_reset" = "Semana de volta às configurações gerais"; "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)])
}
}
}
+2
View File
@@ -249,6 +249,7 @@ struct HomeView: View {
guard let settings = settings, guard let settings = settings,
let plan = ensureCurrentWeekPlanExists(settings: settings) else { return } let plan = ensureCurrentWeekPlanExists(settings: settings) else { return }
viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context) 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() language: settings.languageEnum.resolved()
) )
wasWeekComplete = isWeekComplete(plan: plan) wasWeekComplete = isWeekComplete(plan: plan)
HouseholdSyncService.shared.focus(weekStart: viewModel.currentWeekStart)
evaluatePostOnboardingPromptsIfNeeded(plan: plan, settings: settings) evaluatePostOnboardingPromptsIfNeeded(plan: plan, settings: settings)
evaluateWidgetPromo() evaluateWidgetPromo()
if settings.isPremium { if settings.isPremium {
+309
View File
@@ -0,0 +1,309 @@
import SwiftUI
import SwiftData
import AuthenticationServices
/// Household screen: sign in, create or join a household, see who is in it and
/// leave. The content sync itself runs in `HouseholdSyncService`.
struct HouseholdView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@Query private var allSettings: [AppSettings]
@StateObject private var auth = AuthService.shared
@StateObject private var households = HouseholdService.shared
@StateObject private var sync = HouseholdSyncService.shared
@State private var householdName: String = ""
@State private var joinCode: String = ""
@State private var isWorking = false
@State private var errorMessage: String?
@State private var showJoinChoice = false
@State private var showLeaveConfirm = false
@State private var showPaywall = false
private var settings: AppSettings? { allSettings.first }
private var isPremium: Bool { settings?.isPremium ?? false }
private var isInHousehold: Bool { HouseholdRuntime.householdId != nil }
var body: some View {
Form {
if HouseholdRuntime.needsRelaunch {
Section {
Label("household_relaunch_needed", systemImage: "arrow.clockwise.circle.fill")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodWarning)
}
.listRowBackground(Color.mealMoodSurface)
}
if !auth.isSignedIn {
signInSection
} else if isInHousehold {
householdSection
membersSection
inviteSection
leaveSection
} else {
createSection
joinSection
}
if let errorMessage {
Section {
Text(errorMessage)
.font(.mealMoodCaption)
.foregroundColor(.mealMoodWarning)
}
.listRowBackground(Color.mealMoodSurface)
}
}
.scrollContentBackground(.hidden)
.background(Color.mealMoodBackground.ignoresSafeArea())
.tint(.mealMoodCoral)
.navigationTitle("household_title")
.navigationBarTitleDisplayMode(.inline)
.disabled(isWorking)
.onAppear {
households.start()
AnalyticsService.logScreenView("Household")
}
.sheet(isPresented: $showPaywall) {
if let settings {
NavigationStack {
PremiumView(settings: settings, source: "household")
}
}
}
.confirmationDialog("household_join_choice_title", isPresented: $showJoinChoice, titleVisibility: .visible) {
Button("household_join_choice_merge") { join(bringingLocalContent: true) }
Button("household_join_choice_replace", role: .destructive) { join(bringingLocalContent: false) }
Button("reset_cancel", role: .cancel) {}
} message: {
Text("household_join_choice_message")
}
.alert("household_leave_confirm_title", isPresented: $showLeaveConfirm) {
Button("reset_cancel", role: .cancel) {}
Button("household_leave_confirm_confirm", role: .destructive) { leave() }
} message: {
Text("household_leave_confirm_message")
}
}
// MARK: - Sections
private var signInSection: some View {
Section {
Text("household_intro")
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
SignInWithAppleButton(.signIn) { request in
auth.prepare(request: request)
} onCompletion: { result in
Task {
do {
try await auth.completeSignInWithApple(result)
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
}
.signInWithAppleButtonStyle(.black)
.frame(height: 46)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
} footer: {
Text("household_sign_in_footer")
}
.listRowBackground(Color.mealMoodSurface)
}
private var createSection: some View {
Section {
TextField("household_name_placeholder", text: $householdName)
Button {
guard isPremium else {
showPaywall = true
AnalyticsService.logEvent("paywall_viewed", parameters: ["source": "household_create"])
return
}
create()
} label: {
HStack {
Label("household_create", systemImage: "house.fill")
if !isPremium {
Spacer()
Image(systemName: "star.circle.fill").foregroundColor(.mealMoodCoral)
}
}
}
.disabled(householdName.trimmingCharacters(in: .whitespaces).isEmpty)
} header: {
Text("household_create_section")
} footer: {
if isPremium {
Text("household_create_footer")
} else {
Text("household_create_premium_footer")
}
}
.listRowBackground(Color.mealMoodSurface)
}
private var joinSection: some View {
Section {
TextField("household_code_placeholder", text: $joinCode)
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
Button {
showJoinChoice = true
} label: {
Label("household_join", systemImage: "person.badge.plus")
}
.disabled(!HouseholdService.isPlausibleCode(joinCode))
} header: {
Text("household_join_section")
} footer: {
Text("household_join_footer")
}
.listRowBackground(Color.mealMoodSurface)
}
private var householdSection: some View {
Section {
HStack {
Label(households.household?.name ?? HouseholdRuntime.householdName ?? "", systemImage: "house.fill")
Spacer()
if sync.isSyncing {
ProgressView()
} else if let lastSyncedAt = sync.lastSyncedAt {
Text(lastSyncedAt.formatted(date: .omitted, time: .shortened))
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
}
}
if let syncError = sync.lastError {
Text(syncError)
.font(.mealMoodCaption)
.foregroundColor(.mealMoodWarning)
}
} header: {
Text("household_section")
}
.listRowBackground(Color.mealMoodSurface)
}
private var membersSection: some View {
Section {
ForEach(households.members) { member in
HStack {
Label(
member.displayName.isEmpty ? String(localized: "household_member_unnamed") : member.displayName,
systemImage: member.role == "owner" ? "crown.fill" : "person.fill"
)
Spacer()
if member.id == auth.uid {
Text("household_member_you")
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
}
}
}
} header: {
Text("household_members_section")
}
.listRowBackground(Color.mealMoodSurface)
}
private var inviteSection: some View {
Section {
if let code = households.household?.inviteCode {
HStack {
Text(code)
.font(.system(.title3, design: .monospaced))
.fontWeight(.bold)
.foregroundColor(.mealMoodTextPrimary)
Spacer()
ShareLink(item: String(format: String(localized: "household_invite_share"), code)) {
Image(systemName: "square.and.arrow.up")
}
}
if let expiry = households.household?.inviteExpiresAt {
Text(String(format: String(localized: "household_invite_expires"),
expiry.formatted(date: .abbreviated, time: .omitted)))
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
}
}
Button {
regenerateCode()
} label: {
Label("household_invite_regenerate", systemImage: "arrow.triangle.2.circlepath")
}
} header: {
Text("household_invite_section")
} footer: {
Text("household_invite_footer")
}
.listRowBackground(Color.mealMoodSurface)
}
private var leaveSection: some View {
Section {
Button(role: .destructive) {
showLeaveConfirm = true
} label: {
Label("household_leave", systemImage: "rectangle.portrait.and.arrow.right")
}
}
.listRowBackground(Color.mealMoodSurface)
}
// MARK: - Actions
private func create() {
run {
let name = householdName.trimmingCharacters(in: .whitespaces)
_ = try await households.createHousehold(name: name, displayName: auth.displayName ?? "")
// Everything already planned on this device becomes the household's
// starting point.
await sync.seedNewHousehold()
}
}
private func join(bringingLocalContent: Bool) {
run {
_ = try await households.join(code: joinCode, displayName: auth.displayName ?? "")
if !bringingLocalContent {
sync.replaceLocalContent(context: context)
}
joinCode = ""
}
}
private func leave() {
run {
try await households.leaveHousehold()
}
}
private func regenerateCode() {
run {
try await households.regenerateInviteCode()
}
}
private func run(_ operation: @escaping () async throws -> Void) {
isWorking = true
errorMessage = nil
Task {
do {
try await operation()
} catch {
errorMessage = error.localizedDescription
CrashlyticsService.record(error, context: "household_action")
}
isWorking = false
}
}
}
@@ -227,6 +227,26 @@ struct SettingsView: View {
.listRowBackground(Color.mealMoodSurface) .listRowBackground(Color.mealMoodSurface)
.task { await refreshNotificationStatus() } .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 // Tags section
Section { Section {
NavigationLink(destination: TagListView()) { NavigationLink(destination: TagListView()) {
+241
View File
@@ -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)")
}
}
+40
View File
@@ -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"))
}
}
+112
View File
@@ -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.
+96
View File
@@ -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);
}
}
}
}