Revert "hogar compartido: colaboracion entre cuentas sobre Firestore"
El hogar compartido cambia donde viven los datos, asi que se va a la 3.0 y la 2.x queda para correcciones. El trabajo sigue vivo en la rama 3.0.0 (163fd60). Importante para esta rama: esto tambien retira el entitlement com.apple.developer.applesignin, que sin la capability marcada en el App ID habria roto la firma al subir la 2.1.1 a TestFlight. This reverts commit163fd60. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246
This commit is contained in:
@@ -13,11 +13,9 @@
|
|||||||
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 */; };
|
||||||
@@ -26,16 +24,13 @@
|
|||||||
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 */; };
|
||||||
@@ -61,16 +56,13 @@
|
|||||||
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 */; };
|
||||||
@@ -92,7 +84,6 @@
|
|||||||
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 */; };
|
||||||
@@ -118,7 +109,6 @@
|
|||||||
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 */; };
|
||||||
@@ -218,23 +208,18 @@
|
|||||||
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>"; };
|
||||||
@@ -257,7 +242,6 @@
|
|||||||
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>"; };
|
||||||
@@ -274,12 +258,10 @@
|
|||||||
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>"; };
|
||||||
@@ -362,8 +344,6 @@
|
|||||||
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;
|
||||||
};
|
};
|
||||||
@@ -431,19 +411,6 @@
|
|||||||
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 = (
|
||||||
@@ -456,7 +423,6 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
0F46C32F97EAD52F8F3BC0B7 /* SettingsView.swift */,
|
0F46C32F97EAD52F8F3BC0B7 /* SettingsView.swift */,
|
||||||
A20BF9DA2DD0717F66A3A574 /* HouseholdView.swift */,
|
|
||||||
);
|
);
|
||||||
path = Settings;
|
path = Settings;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -541,7 +507,6 @@
|
|||||||
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>";
|
||||||
@@ -631,7 +596,6 @@
|
|||||||
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;
|
||||||
@@ -729,7 +693,6 @@
|
|||||||
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>";
|
||||||
@@ -827,8 +790,6 @@
|
|||||||
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 */;
|
||||||
@@ -969,7 +930,6 @@
|
|||||||
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;
|
||||||
};
|
};
|
||||||
@@ -1013,7 +973,6 @@
|
|||||||
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;
|
||||||
};
|
};
|
||||||
@@ -1096,12 +1055,6 @@
|
|||||||
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;
|
||||||
};
|
};
|
||||||
@@ -1633,16 +1586,6 @@
|
|||||||
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" */;
|
||||||
|
|||||||
@@ -33,11 +33,6 @@ 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 {
|
||||||
|
|||||||
@@ -49,25 +49,17 @@ 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.).
|
||||||
if !HouseholdRuntime.isHouseholdStore {
|
let cloudConfiguration = ModelConfiguration(cloudKitDatabase: .automatic)
|
||||||
let cloudConfiguration = ModelConfiguration(cloudKitDatabase: .automatic)
|
do {
|
||||||
do {
|
let container = try ModelContainer(for: schema, configurations: [cloudConfiguration])
|
||||||
let container = try ModelContainer(for: schema, configurations: [cloudConfiguration])
|
CloudSyncRuntime.isCloudKitActive = true
|
||||||
CloudSyncRuntime.isCloudKitActive = true
|
return container
|
||||||
return container
|
} catch {
|
||||||
} catch {
|
CrashlyticsService.record(error, context: "cloudkit_container")
|
||||||
CrashlyticsService.record(error, context: "cloudkit_container")
|
print("CloudKit container unavailable, using local store: \(error)")
|
||||||
print("CloudKit container unavailable, using local store: \(error)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let configuration = ModelConfiguration(cloudKitDatabase: .none)
|
let configuration = ModelConfiguration(cloudKitDatabase: .none)
|
||||||
|
|||||||
@@ -14,10 +14,6 @@
|
|||||||
</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,40 +478,3 @@
|
|||||||
"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,40 +478,3 @@
|
|||||||
"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,40 +478,3 @@
|
|||||||
"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,40 +478,3 @@
|
|||||||
"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,40 +478,3 @@
|
|||||||
"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,40 +478,3 @@
|
|||||||
"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.";
|
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import AuthenticationServices
|
|
||||||
import CryptoKit
|
|
||||||
import FirebaseAuth
|
|
||||||
|
|
||||||
/// Firebase Auth wrapper for the household feature. Sign in with Apple only for
|
|
||||||
/// now — it is the one provider every iOS user already has, and Apple requires
|
|
||||||
/// offering it anyway once other social logins appear (Google arrives with the
|
|
||||||
/// Android PWA).
|
|
||||||
@MainActor
|
|
||||||
final class AuthService: ObservableObject {
|
|
||||||
static let shared = AuthService()
|
|
||||||
|
|
||||||
@Published private(set) var uid: String?
|
|
||||||
@Published private(set) var displayName: String?
|
|
||||||
|
|
||||||
/// Raw nonce of the sign-in in flight. Apple returns its SHA-256 inside the
|
|
||||||
/// identity token, and Firebase checks both match — that is what stops a
|
|
||||||
/// stolen token from being replayed.
|
|
||||||
private var currentNonce: String?
|
|
||||||
|
|
||||||
private var stateListener: AuthStateDidChangeListenerHandle?
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
let user = Auth.auth().currentUser
|
|
||||||
uid = user?.uid
|
|
||||||
displayName = user?.displayName
|
|
||||||
stateListener = Auth.auth().addStateDidChangeListener { [weak self] _, user in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.uid = user?.uid
|
|
||||||
self?.displayName = user?.displayName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isSignedIn: Bool { uid != nil }
|
|
||||||
|
|
||||||
// MARK: - Sign in with Apple
|
|
||||||
|
|
||||||
/// Prepares the `ASAuthorizationAppleIDRequest` handed to
|
|
||||||
/// `SignInWithAppleButton`.
|
|
||||||
func prepare(request: ASAuthorizationAppleIDRequest) {
|
|
||||||
let nonce = Self.randomNonce()
|
|
||||||
currentNonce = nonce
|
|
||||||
request.requestedScopes = [.fullName, .email]
|
|
||||||
request.nonce = Self.sha256(nonce)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Exchanges Apple's credential for a Firebase session.
|
|
||||||
func completeSignInWithApple(_ result: Result<ASAuthorization, Error>) async throws {
|
|
||||||
switch result {
|
|
||||||
case .failure(let error):
|
|
||||||
// The user cancelling is not a failure worth surfacing.
|
|
||||||
if (error as? ASAuthorizationError)?.code == .canceled { return }
|
|
||||||
throw error
|
|
||||||
|
|
||||||
case .success(let authorization):
|
|
||||||
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
|
|
||||||
let tokenData = credential.identityToken,
|
|
||||||
let idToken = String(data: tokenData, encoding: .utf8) else {
|
|
||||||
throw AuthError.missingAppleToken
|
|
||||||
}
|
|
||||||
guard let nonce = currentNonce else {
|
|
||||||
throw AuthError.missingNonce
|
|
||||||
}
|
|
||||||
|
|
||||||
let firebaseCredential = OAuthProvider.appleCredential(
|
|
||||||
withIDToken: idToken,
|
|
||||||
rawNonce: nonce,
|
|
||||||
fullName: credential.fullName
|
|
||||||
)
|
|
||||||
let result = try await Auth.auth().signIn(with: firebaseCredential)
|
|
||||||
currentNonce = nil
|
|
||||||
|
|
||||||
// Apple only sends the name on the very first authorization, so it
|
|
||||||
// has to be persisted right away or it is lost forever.
|
|
||||||
if let fullName = credential.fullName {
|
|
||||||
let formatter = PersonNameComponentsFormatter()
|
|
||||||
let name = formatter.string(from: fullName).trimmingCharacters(in: .whitespaces)
|
|
||||||
if !name.isEmpty, result.user.displayName?.isEmpty ?? true {
|
|
||||||
let change = result.user.createProfileChangeRequest()
|
|
||||||
change.displayName = name
|
|
||||||
try? await change.commitChanges()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
uid = result.user.uid
|
|
||||||
displayName = result.user.displayName
|
|
||||||
AnalyticsService.logEvent("household_signed_in")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func signOut() throws {
|
|
||||||
try Auth.auth().signOut()
|
|
||||||
uid = nil
|
|
||||||
displayName = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Nonce
|
|
||||||
|
|
||||||
private static func randomNonce(length: Int = 32) -> String {
|
|
||||||
let charset = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._")
|
|
||||||
var result = ""
|
|
||||||
var remaining = length
|
|
||||||
|
|
||||||
while remaining > 0 {
|
|
||||||
let randoms: [UInt8] = (0..<16).map { _ in
|
|
||||||
var random: UInt8 = 0
|
|
||||||
let status = SecRandomCopyBytes(kSecRandomDefault, 1, &random)
|
|
||||||
// A failing RNG must not silently degrade into a guessable nonce.
|
|
||||||
precondition(status == errSecSuccess, "SecRandomCopyBytes failed: \(status)")
|
|
||||||
return random
|
|
||||||
}
|
|
||||||
for random in randoms where remaining > 0 {
|
|
||||||
if random < charset.count {
|
|
||||||
result.append(charset[Int(random)])
|
|
||||||
remaining -= 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func sha256(_ input: String) -> String {
|
|
||||||
SHA256.hash(data: Data(input.utf8))
|
|
||||||
.map { String(format: "%02x", $0) }
|
|
||||||
.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
enum AuthError: LocalizedError {
|
|
||||||
case missingAppleToken
|
|
||||||
case missingNonce
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .missingAppleToken: return "Apple did not return an identity token."
|
|
||||||
case .missingNonce: return "Sign-in was not started by this app."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
/// Translation between the SwiftData models and the Firestore documents of a
|
|
||||||
/// household, plus the document ids and the content hashing the sync uses to
|
|
||||||
/// tell "changed locally" from "unchanged".
|
|
||||||
///
|
|
||||||
/// `calendarEventId` and `photoData` are deliberately absent: the first is an
|
|
||||||
/// EventKit id that only means something on the device that created it, and the
|
|
||||||
/// second needs Firebase Storage (fase 2 in docs/household-sync.md).
|
|
||||||
enum HouseholdDocuments {
|
|
||||||
|
|
||||||
// MARK: - Document ids
|
|
||||||
|
|
||||||
/// Week documents are keyed by their Monday ("2026-09-14") instead of a
|
|
||||||
/// UUID so two members opening the same week write the same document
|
|
||||||
/// instead of creating two.
|
|
||||||
static func weekKey(for weekStart: Date) -> String {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.calendar = Calendar(identifier: .gregorian)
|
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
formatter.dateFormat = "yyyy-MM-dd"
|
|
||||||
return formatter.string(from: weekStart)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Same idea for slots: "5-dinner" is derived from the content, so the
|
|
||||||
/// Saturday dinner of a week is one document no matter who creates it.
|
|
||||||
static func slotId(dayOfWeek: Int, mealType: String) -> String {
|
|
||||||
"\(dayOfWeek)-\(mealType)"
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Dish
|
|
||||||
|
|
||||||
static func fields(for dish: Dish) -> [String: Any] {
|
|
||||||
[
|
|
||||||
"name": dish.name,
|
|
||||||
"descriptionText": dish.descriptionText ?? NSNull(),
|
|
||||||
"tagIds": dish.tagIds.map(\.uuidString),
|
|
||||||
"ingredients": dish.ingredients,
|
|
||||||
"isPriority": dish.isPriority,
|
|
||||||
"fixedDayOfWeek": dish.fixedDayOfWeek ?? NSNull(),
|
|
||||||
"fixedMealType": dish.fixedMealType ?? NSNull(),
|
|
||||||
"createdAt": dish.createdAt.timeIntervalSince1970
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
static func apply(_ data: [String: Any], to dish: Dish) {
|
|
||||||
dish.name = data["name"] as? String ?? dish.name
|
|
||||||
dish.descriptionText = data["descriptionText"] as? String
|
|
||||||
dish.tagIds = (data["tagIds"] as? [String] ?? []).compactMap(UUID.init(uuidString:))
|
|
||||||
dish.ingredients = data["ingredients"] as? [String] ?? []
|
|
||||||
dish.isPriority = data["isPriority"] as? Bool ?? false
|
|
||||||
dish.fixedDayOfWeek = data["fixedDayOfWeek"] as? Int
|
|
||||||
dish.fixedMealType = data["fixedMealType"] as? String
|
|
||||||
if let created = data["createdAt"] as? Double {
|
|
||||||
dish.createdAt = Date(timeIntervalSince1970: created)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Tag
|
|
||||||
|
|
||||||
static func fields(for tag: Tag) -> [String: Any] {
|
|
||||||
[
|
|
||||||
"name": tag.name,
|
|
||||||
"nameEN": tag.nameEN,
|
|
||||||
"color": tag.color,
|
|
||||||
"maxPerWeek": tag.maxPerWeek ?? NSNull(),
|
|
||||||
"noConsecutive": tag.noConsecutive,
|
|
||||||
"noDuplicateInDay": tag.noDuplicateInDay,
|
|
||||||
"mealTypeRestriction": tag.mealTypeRestriction ?? NSNull(),
|
|
||||||
"dayRestriction": tag.dayRestriction ?? NSNull(),
|
|
||||||
"isDefault": tag.isDefault,
|
|
||||||
"sortOrder": tag.sortOrder
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
static func apply(_ data: [String: Any], to tag: Tag) {
|
|
||||||
tag.name = data["name"] as? String ?? tag.name
|
|
||||||
tag.nameEN = data["nameEN"] as? String ?? tag.nameEN
|
|
||||||
tag.color = data["color"] as? String ?? tag.color
|
|
||||||
tag.maxPerWeek = data["maxPerWeek"] as? Int
|
|
||||||
tag.noConsecutive = data["noConsecutive"] as? Bool ?? false
|
|
||||||
tag.noDuplicateInDay = data["noDuplicateInDay"] as? Bool ?? false
|
|
||||||
tag.mealTypeRestriction = data["mealTypeRestriction"] as? String
|
|
||||||
tag.dayRestriction = data["dayRestriction"] as? String
|
|
||||||
tag.isDefault = data["isDefault"] as? Bool ?? false
|
|
||||||
tag.sortOrder = data["sortOrder"] as? Int ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - WeekPlan
|
|
||||||
|
|
||||||
static func fields(for plan: WeekPlan) -> [String: Any] {
|
|
||||||
[
|
|
||||||
"weekStartDate": plan.weekStartDate.timeIntervalSince1970,
|
|
||||||
"userRating": plan.userRating,
|
|
||||||
"includeWeekendsOverride": plan.includeWeekendsOverride ?? NSNull(),
|
|
||||||
"mealTypesOverrideRaw": plan.mealTypesOverrideRaw ?? NSNull()
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
static func apply(_ data: [String: Any], to plan: WeekPlan) {
|
|
||||||
plan.userRating = data["userRating"] as? Int ?? 0
|
|
||||||
plan.includeWeekendsOverride = data["includeWeekendsOverride"] as? Bool
|
|
||||||
plan.mealTypesOverrideRaw = data["mealTypesOverrideRaw"] as? String
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - MealSlot
|
|
||||||
|
|
||||||
static func fields(for slot: MealSlot) -> [String: Any] {
|
|
||||||
[
|
|
||||||
"dayOfWeek": slot.dayOfWeek,
|
|
||||||
"mealType": slot.mealType,
|
|
||||||
"dishId": slot.dishId?.uuidString ?? NSNull(),
|
|
||||||
"secondaryDishId": slot.secondaryDishId?.uuidString ?? NSNull(),
|
|
||||||
"isEatingOut": slot.isEatingOut,
|
|
||||||
"isSkipped": slot.isSkipped,
|
|
||||||
"isRuleOverridden": slot.isRuleOverridden,
|
|
||||||
"isRuleIgnored": slot.isRuleIgnored
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
static func apply(_ data: [String: Any], to slot: MealSlot) {
|
|
||||||
slot.dishId = (data["dishId"] as? String).flatMap(UUID.init(uuidString:))
|
|
||||||
slot.secondaryDishId = (data["secondaryDishId"] as? String).flatMap(UUID.init(uuidString:))
|
|
||||||
slot.isEatingOut = data["isEatingOut"] as? Bool ?? false
|
|
||||||
slot.isSkipped = data["isSkipped"] as? Bool ?? false
|
|
||||||
slot.isRuleOverridden = data["isRuleOverridden"] as? Bool ?? false
|
|
||||||
slot.isRuleIgnored = data["isRuleIgnored"] as? Bool ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - ShoppingItem
|
|
||||||
|
|
||||||
static func fields(for item: ShoppingItem) -> [String: Any] {
|
|
||||||
[
|
|
||||||
"weekStartDate": item.weekStartDate.timeIntervalSince1970,
|
|
||||||
"title": item.title,
|
|
||||||
"dishId": item.dishId?.uuidString ?? NSNull(),
|
|
||||||
"isChecked": item.isChecked,
|
|
||||||
"isDismissed": item.isDismissed,
|
|
||||||
"sortOrder": item.sortOrder,
|
|
||||||
"createdAt": item.createdAt.timeIntervalSince1970
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
static func apply(_ data: [String: Any], to item: ShoppingItem) {
|
|
||||||
item.title = data["title"] as? String ?? item.title
|
|
||||||
item.dishId = (data["dishId"] as? String).flatMap(UUID.init(uuidString:))
|
|
||||||
item.isChecked = data["isChecked"] as? Bool ?? false
|
|
||||||
item.isDismissed = data["isDismissed"] as? Bool ?? false
|
|
||||||
item.sortOrder = data["sortOrder"] as? Int ?? 0
|
|
||||||
if let created = data["createdAt"] as? Double {
|
|
||||||
item.createdAt = Date(timeIntervalSince1970: created)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Content hashing
|
|
||||||
|
|
||||||
/// Stable fingerprint of a document's payload. The sync keeps the last
|
|
||||||
/// synced fingerprint per id (the "shadow") and pushes only what differs,
|
|
||||||
/// which is what lets the app skip `updatedAt` bookkeeping in every view
|
|
||||||
/// that mutates a model.
|
|
||||||
static func fingerprint(_ fields: [String: Any]) -> String {
|
|
||||||
var parts: [String] = []
|
|
||||||
for key in fields.keys.sorted() {
|
|
||||||
parts.append("\(key)=\(describe(fields[key]))")
|
|
||||||
}
|
|
||||||
let joined = parts.joined(separator: "&")
|
|
||||||
return SHA256.hash(data: Data(joined.utf8))
|
|
||||||
.prefix(8)
|
|
||||||
.map { String(format: "%02x", $0) }
|
|
||||||
.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `Bool` and `Int` both bridge to `NSNumber`, so a plain `as? Bool` cast
|
|
||||||
/// would make `true` and `1` hash the same. CFBoolean is what tells them
|
|
||||||
/// apart.
|
|
||||||
private static func describe(_ value: Any?) -> String {
|
|
||||||
switch value {
|
|
||||||
case nil, is NSNull:
|
|
||||||
return "~"
|
|
||||||
case let string as String:
|
|
||||||
return string
|
|
||||||
case let array as [String]:
|
|
||||||
return "[" + array.joined(separator: ",") + "]"
|
|
||||||
case let number as NSNumber:
|
|
||||||
if CFGetTypeID(number) == CFBooleanGetTypeID() {
|
|
||||||
return number.boolValue ? "true" : "false"
|
|
||||||
}
|
|
||||||
if CFNumberIsFloatType(number as CFNumber) {
|
|
||||||
return String(format: "%.3f", number.doubleValue)
|
|
||||||
}
|
|
||||||
return number.stringValue
|
|
||||||
default:
|
|
||||||
return String(describing: value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Where the app's data lives this launch.
|
|
||||||
///
|
|
||||||
/// A device inside a household syncs through Firestore and must NOT also run
|
|
||||||
/// CloudKit: two mirrors writing the same objects fight each other (the same
|
|
||||||
/// reason the legacy iCloud KV sync is disabled whenever CloudKit is active).
|
|
||||||
/// The store configuration is fixed when the `ModelContainer` is built at
|
|
||||||
/// launch, so joining or leaving a household only takes effect after a relaunch
|
|
||||||
/// — `needsRelaunch` tells the UI when to say so.
|
|
||||||
enum HouseholdRuntime {
|
|
||||||
private static let householdIdKey = "household_id"
|
|
||||||
private static let householdNameKey = "household_name"
|
|
||||||
|
|
||||||
/// Household this device belongs to, or nil when planning solo.
|
|
||||||
static var householdId: String? {
|
|
||||||
get { UserDefaults.standard.string(forKey: householdIdKey) }
|
|
||||||
set { UserDefaults.standard.set(newValue, forKey: householdIdKey) }
|
|
||||||
}
|
|
||||||
|
|
||||||
static var householdName: String? {
|
|
||||||
get { UserDefaults.standard.string(forKey: householdNameKey) }
|
|
||||||
set { UserDefaults.standard.set(newValue, forKey: householdNameKey) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the store built at launch is the household (local + Firestore)
|
|
||||||
/// one. Written once during app start, read-only afterwards.
|
|
||||||
nonisolated(unsafe) static var isHouseholdStore = false
|
|
||||||
|
|
||||||
/// True when the membership changed since the store was built, so the app
|
|
||||||
/// is running on the wrong store until it relaunches.
|
|
||||||
static var needsRelaunch: Bool {
|
|
||||||
(householdId != nil) != isHouseholdStore
|
|
||||||
}
|
|
||||||
|
|
||||||
static func clear() {
|
|
||||||
householdId = nil
|
|
||||||
householdName = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import FirebaseFirestore
|
|
||||||
|
|
||||||
struct HouseholdSummary: Identifiable, Equatable {
|
|
||||||
let id: String
|
|
||||||
var name: String
|
|
||||||
var ownerId: String
|
|
||||||
var memberIds: [String]
|
|
||||||
var inviteCode: String?
|
|
||||||
var inviteExpiresAt: Date?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct HouseholdMember: Identifiable, Equatable {
|
|
||||||
let id: String // uid
|
|
||||||
var displayName: String
|
|
||||||
var role: String // "owner" | "member"
|
|
||||||
var joinedAt: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creating a household, inviting people into it and leaving it. The content
|
|
||||||
/// sync itself lives in `HouseholdSyncService`.
|
|
||||||
@MainActor
|
|
||||||
final class HouseholdService: ObservableObject {
|
|
||||||
static let shared = HouseholdService()
|
|
||||||
|
|
||||||
private let db = Firestore.firestore()
|
|
||||||
|
|
||||||
@Published private(set) var household: HouseholdSummary?
|
|
||||||
@Published private(set) var members: [HouseholdMember] = []
|
|
||||||
|
|
||||||
private var householdListener: ListenerRegistration?
|
|
||||||
private var membersListener: ListenerRegistration?
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
|
||||||
|
|
||||||
/// Starts watching the household this device belongs to, if any.
|
|
||||||
func start() {
|
|
||||||
guard let householdId = HouseholdRuntime.householdId else { return }
|
|
||||||
observe(householdId: householdId)
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
householdListener?.remove()
|
|
||||||
membersListener?.remove()
|
|
||||||
householdListener = nil
|
|
||||||
membersListener = nil
|
|
||||||
household = nil
|
|
||||||
members = []
|
|
||||||
}
|
|
||||||
|
|
||||||
private func observe(householdId: String) {
|
|
||||||
householdListener?.remove()
|
|
||||||
membersListener?.remove()
|
|
||||||
|
|
||||||
householdListener = db.collection("households").document(householdId)
|
|
||||||
.addSnapshotListener { [weak self] snapshot, _ in
|
|
||||||
guard let data = snapshot?.data() else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.household = Self.summary(id: householdId, data: data)
|
|
||||||
HouseholdRuntime.householdName = data["name"] as? String
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
membersListener = db.collection("households").document(householdId).collection("members")
|
|
||||||
.addSnapshotListener { [weak self] snapshot, _ in
|
|
||||||
let members = (snapshot?.documents ?? []).map { document -> HouseholdMember in
|
|
||||||
let data = document.data()
|
|
||||||
return HouseholdMember(
|
|
||||||
id: document.documentID,
|
|
||||||
displayName: data["displayName"] as? String ?? "",
|
|
||||||
role: data["role"] as? String ?? "member",
|
|
||||||
joinedAt: (data["joinedAt"] as? Timestamp)?.dateValue() ?? Date()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.members = members.sorted { $0.joinedAt < $1.joinedAt }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func summary(id: String, data: [String: Any]) -> HouseholdSummary {
|
|
||||||
HouseholdSummary(
|
|
||||||
id: id,
|
|
||||||
name: data["name"] as? String ?? "",
|
|
||||||
ownerId: data["ownerId"] as? String ?? "",
|
|
||||||
memberIds: data["memberIds"] as? [String] ?? [],
|
|
||||||
inviteCode: data["inviteCode"] as? String,
|
|
||||||
inviteExpiresAt: (data["inviteExpiresAt"] as? Timestamp)?.dateValue()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Create / join / leave
|
|
||||||
|
|
||||||
func createHousehold(name: String, displayName: String) async throws -> HouseholdSummary {
|
|
||||||
guard let uid = AuthService.shared.uid else { throw HouseholdError.notSignedIn }
|
|
||||||
|
|
||||||
let householdId = UUID().uuidString
|
|
||||||
let code = Self.makeInviteCode()
|
|
||||||
let expiresAt = Date().addingTimeInterval(Self.inviteLifetime)
|
|
||||||
let householdRef = db.collection("households").document(householdId)
|
|
||||||
|
|
||||||
let batch = db.batch()
|
|
||||||
batch.setData([
|
|
||||||
"name": name,
|
|
||||||
"ownerId": uid,
|
|
||||||
"memberIds": [uid],
|
|
||||||
"createdBy": uid,
|
|
||||||
"createdAt": FieldValue.serverTimestamp(),
|
|
||||||
"inviteCode": code,
|
|
||||||
"inviteExpiresAt": Timestamp(date: expiresAt)
|
|
||||||
], forDocument: householdRef)
|
|
||||||
|
|
||||||
batch.setData([
|
|
||||||
"displayName": displayName,
|
|
||||||
"role": "owner",
|
|
||||||
"joinedAt": FieldValue.serverTimestamp()
|
|
||||||
], forDocument: householdRef.collection("members").document(uid))
|
|
||||||
|
|
||||||
batch.setData([
|
|
||||||
"householdId": householdId,
|
|
||||||
"createdBy": uid,
|
|
||||||
"expiresAt": Timestamp(date: expiresAt)
|
|
||||||
], forDocument: db.collection("invites").document(code))
|
|
||||||
|
|
||||||
batch.setData([
|
|
||||||
"householdId": householdId,
|
|
||||||
"displayName": displayName,
|
|
||||||
"updatedAt": FieldValue.serverTimestamp()
|
|
||||||
], forDocument: db.collection("users").document(uid), merge: true)
|
|
||||||
|
|
||||||
try await batch.commit()
|
|
||||||
|
|
||||||
HouseholdRuntime.householdId = householdId
|
|
||||||
HouseholdRuntime.householdName = name
|
|
||||||
observe(householdId: householdId)
|
|
||||||
AnalyticsService.logEvent("household_created")
|
|
||||||
|
|
||||||
return HouseholdSummary(
|
|
||||||
id: householdId, name: name, ownerId: uid, memberIds: [uid],
|
|
||||||
inviteCode: code, inviteExpiresAt: expiresAt
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func join(code rawCode: String, displayName: String) async throws -> HouseholdSummary {
|
|
||||||
guard let uid = AuthService.shared.uid else { throw HouseholdError.notSignedIn }
|
|
||||||
let code = Self.normalize(rawCode)
|
|
||||||
|
|
||||||
let inviteSnapshot = try await db.collection("invites").document(code).getDocument()
|
|
||||||
guard let invite = inviteSnapshot.data(),
|
|
||||||
let householdId = invite["householdId"] as? String else {
|
|
||||||
throw HouseholdError.invalidCode
|
|
||||||
}
|
|
||||||
if let expiresAt = (invite["expiresAt"] as? Timestamp)?.dateValue(), expiresAt < Date() {
|
|
||||||
throw HouseholdError.expiredCode
|
|
||||||
}
|
|
||||||
|
|
||||||
let householdRef = db.collection("households").document(householdId)
|
|
||||||
// `lastJoinCode` is what the security rules check to allow a stranger to
|
|
||||||
// add themselves to `memberIds` — see firestore.rules.
|
|
||||||
try await householdRef.updateData([
|
|
||||||
"memberIds": FieldValue.arrayUnion([uid]),
|
|
||||||
"lastJoinCode": code
|
|
||||||
])
|
|
||||||
try await householdRef.collection("members").document(uid).setData([
|
|
||||||
"displayName": displayName,
|
|
||||||
"role": "member",
|
|
||||||
"joinedAt": FieldValue.serverTimestamp()
|
|
||||||
])
|
|
||||||
try await db.collection("users").document(uid).setData([
|
|
||||||
"householdId": householdId,
|
|
||||||
"displayName": displayName,
|
|
||||||
"updatedAt": FieldValue.serverTimestamp()
|
|
||||||
], merge: true)
|
|
||||||
|
|
||||||
let snapshot = try await householdRef.getDocument()
|
|
||||||
let summary = Self.summary(id: householdId, data: snapshot.data() ?? [:])
|
|
||||||
|
|
||||||
HouseholdRuntime.householdId = householdId
|
|
||||||
HouseholdRuntime.householdName = summary.name
|
|
||||||
observe(householdId: householdId)
|
|
||||||
AnalyticsService.logEvent("household_joined")
|
|
||||||
|
|
||||||
return summary
|
|
||||||
}
|
|
||||||
|
|
||||||
func leaveHousehold() async throws {
|
|
||||||
guard let uid = AuthService.shared.uid else { throw HouseholdError.notSignedIn }
|
|
||||||
guard let householdId = HouseholdRuntime.householdId else { return }
|
|
||||||
|
|
||||||
let householdRef = db.collection("households").document(householdId)
|
|
||||||
let snapshot = try await householdRef.getDocument()
|
|
||||||
let summary = Self.summary(id: householdId, data: snapshot.data() ?? [:])
|
|
||||||
let remaining = summary.memberIds.filter { $0 != uid }
|
|
||||||
|
|
||||||
if remaining.isEmpty {
|
|
||||||
// Last one out: the household and its content go with them.
|
|
||||||
try await householdRef.delete()
|
|
||||||
} else {
|
|
||||||
var updates: [String: Any] = ["memberIds": FieldValue.arrayRemove([uid])]
|
|
||||||
// Hand ownership over instead of leaving an orphan household.
|
|
||||||
if summary.ownerId == uid, let heir = remaining.first {
|
|
||||||
updates["ownerId"] = heir
|
|
||||||
}
|
|
||||||
try await householdRef.updateData(updates)
|
|
||||||
try await householdRef.collection("members").document(uid).delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
try await db.collection("users").document(uid).setData([
|
|
||||||
"householdId": FieldValue.delete()
|
|
||||||
], merge: true)
|
|
||||||
|
|
||||||
stop()
|
|
||||||
HouseholdRuntime.clear()
|
|
||||||
AnalyticsService.logEvent("household_left")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Issues a fresh invite code, invalidating the previous one.
|
|
||||||
@discardableResult
|
|
||||||
func regenerateInviteCode() async throws -> String {
|
|
||||||
guard let uid = AuthService.shared.uid else { throw HouseholdError.notSignedIn }
|
|
||||||
guard let householdId = HouseholdRuntime.householdId else { throw HouseholdError.noHousehold }
|
|
||||||
|
|
||||||
let code = Self.makeInviteCode()
|
|
||||||
let expiresAt = Date().addingTimeInterval(Self.inviteLifetime)
|
|
||||||
let previousCode = household?.inviteCode
|
|
||||||
|
|
||||||
try await db.collection("invites").document(code).setData([
|
|
||||||
"householdId": householdId,
|
|
||||||
"createdBy": uid,
|
|
||||||
"expiresAt": Timestamp(date: expiresAt)
|
|
||||||
])
|
|
||||||
try await db.collection("households").document(householdId).updateData([
|
|
||||||
"inviteCode": code,
|
|
||||||
"inviteExpiresAt": Timestamp(date: expiresAt)
|
|
||||||
])
|
|
||||||
if let previousCode, previousCode != code {
|
|
||||||
try? await db.collection("invites").document(previousCode).delete()
|
|
||||||
}
|
|
||||||
return code
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Invite codes
|
|
||||||
|
|
||||||
nonisolated static let inviteLifetime: TimeInterval = 7 * 24 * 60 * 60
|
|
||||||
|
|
||||||
/// Six characters, no vowels (no accidental words) and no 0/O/1/I so they
|
|
||||||
/// survive being read out loud or typed from a screenshot.
|
|
||||||
nonisolated static let inviteAlphabet = Array("23456789BCDFGHJKLMNPQRSTVWXYZ")
|
|
||||||
|
|
||||||
nonisolated static func makeInviteCode() -> String {
|
|
||||||
String((0..<6).map { _ in inviteAlphabet.randomElement() ?? "X" })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Uppercases and drops anything outside the alphabet, so "bcdf-gh" and
|
|
||||||
/// "BCDF GH" both reach Firestore as "BCDFGH".
|
|
||||||
nonisolated static func normalize(_ code: String) -> String {
|
|
||||||
String(code.uppercased().filter { inviteAlphabet.contains($0) })
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated static func isPlausibleCode(_ code: String) -> Bool {
|
|
||||||
normalize(code).count == 6
|
|
||||||
}
|
|
||||||
|
|
||||||
enum HouseholdError: LocalizedError {
|
|
||||||
case notSignedIn
|
|
||||||
case noHousehold
|
|
||||||
case invalidCode
|
|
||||||
case expiredCode
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .notSignedIn: return String(localized: "household_error_not_signed_in")
|
|
||||||
case .noHousehold: return String(localized: "household_error_no_household")
|
|
||||||
case .invalidCode: return String(localized: "household_error_invalid_code")
|
|
||||||
case .expiredCode: return String(localized: "household_error_expired_code")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,478 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import SwiftData
|
|
||||||
import FirebaseFirestore
|
|
||||||
|
|
||||||
/// Mirrors the household's content between the local SwiftData store and
|
|
||||||
/// Firestore. The UI keeps reading SwiftData through `@Query`; this service is
|
|
||||||
/// the only thing that talks to the network.
|
|
||||||
///
|
|
||||||
/// How changes are detected without touching every mutation site in the app:
|
|
||||||
/// each synced document carries a fingerprint of its content, and the last
|
|
||||||
/// fingerprint pushed or received per document (the "shadow") is persisted.
|
|
||||||
/// Local content whose fingerprint no longer matches the shadow is a local
|
|
||||||
/// edit; an id present in the shadow but gone locally is a local delete, which
|
|
||||||
/// becomes a `deletedAt` tombstone (a hard delete would come back from any
|
|
||||||
/// member who was offline when it happened).
|
|
||||||
///
|
|
||||||
/// Conflicts resolve last-write-wins per document. Documents are small and
|
|
||||||
/// granular — one per meal slot — so two people filling different days of the
|
|
||||||
/// same week never collide.
|
|
||||||
@MainActor
|
|
||||||
final class HouseholdSyncService: ObservableObject {
|
|
||||||
static let shared = HouseholdSyncService()
|
|
||||||
|
|
||||||
@Published private(set) var isSyncing = false
|
|
||||||
@Published private(set) var lastSyncedAt: Date?
|
|
||||||
@Published private(set) var lastError: String?
|
|
||||||
|
|
||||||
private let db = Firestore.firestore()
|
|
||||||
private var context: ModelContext?
|
|
||||||
private var householdId: String?
|
|
||||||
|
|
||||||
private var contentListeners: [ListenerRegistration] = []
|
|
||||||
private var slotListeners: [String: ListenerRegistration] = [:]
|
|
||||||
private var focusedWeeks: [String] = []
|
|
||||||
private static let maxFocusedWeeks = 6
|
|
||||||
|
|
||||||
private var shadow = SyncShadow()
|
|
||||||
private var pushTask: Task<Void, Never>?
|
|
||||||
private var isApplyingRemote = false
|
|
||||||
private var saveObserver: NSObjectProtocol?
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
|
||||||
|
|
||||||
func start(context: ModelContext) {
|
|
||||||
guard let householdId = HouseholdRuntime.householdId,
|
|
||||||
AuthService.shared.isSignedIn,
|
|
||||||
HouseholdRuntime.isHouseholdStore else { return }
|
|
||||||
guard self.householdId != householdId else { return }
|
|
||||||
|
|
||||||
self.context = context
|
|
||||||
self.householdId = householdId
|
|
||||||
shadow.load(householdId: householdId)
|
|
||||||
|
|
||||||
observeContent(householdId: householdId)
|
|
||||||
observeLocalSaves()
|
|
||||||
schedulePush()
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
contentListeners.forEach { $0.remove() }
|
|
||||||
contentListeners = []
|
|
||||||
slotListeners.values.forEach { $0.remove() }
|
|
||||||
slotListeners = [:]
|
|
||||||
focusedWeeks = []
|
|
||||||
if let saveObserver {
|
|
||||||
NotificationCenter.default.removeObserver(saveObserver)
|
|
||||||
}
|
|
||||||
saveObserver = nil
|
|
||||||
pushTask?.cancel()
|
|
||||||
householdId = nil
|
|
||||||
context = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Keeps a listener on the weeks the user is actually looking at. Watching
|
|
||||||
/// every week ever planned would mean a listener per week, forever.
|
|
||||||
func focus(weekStart: Date) {
|
|
||||||
guard let householdId else { return }
|
|
||||||
let key = HouseholdDocuments.weekKey(for: weekStart)
|
|
||||||
guard slotListeners[key] == nil else { return }
|
|
||||||
|
|
||||||
let listener = db.collection("households").document(householdId)
|
|
||||||
.collection("weekPlans").document(key).collection("slots")
|
|
||||||
.addSnapshotListener { [weak self] snapshot, error in
|
|
||||||
guard let snapshot else {
|
|
||||||
Task { @MainActor in self?.lastError = error?.localizedDescription }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.applyRemoteSlots(snapshot, weekKey: key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
slotListeners[key] = listener
|
|
||||||
focusedWeeks.append(key)
|
|
||||||
while focusedWeeks.count > Self.maxFocusedWeeks {
|
|
||||||
let dropped = focusedWeeks.removeFirst()
|
|
||||||
slotListeners.removeValue(forKey: dropped)?.remove()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Remote → local
|
|
||||||
|
|
||||||
private func observeContent(householdId: String) {
|
|
||||||
let household = db.collection("households").document(householdId)
|
|
||||||
|
|
||||||
contentListeners.append(
|
|
||||||
household.collection("dishes").addSnapshotListener { [weak self] snapshot, _ in
|
|
||||||
guard let snapshot else { return }
|
|
||||||
Task { @MainActor in self?.applyRemoteDishes(snapshot) }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
contentListeners.append(
|
|
||||||
household.collection("tags").addSnapshotListener { [weak self] snapshot, _ in
|
|
||||||
guard let snapshot else { return }
|
|
||||||
Task { @MainActor in self?.applyRemoteTags(snapshot) }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
contentListeners.append(
|
|
||||||
household.collection("weekPlans").addSnapshotListener { [weak self] snapshot, _ in
|
|
||||||
guard let snapshot else { return }
|
|
||||||
Task { @MainActor in self?.applyRemoteWeekPlans(snapshot) }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
contentListeners.append(
|
|
||||||
household.collection("shoppingItems").addSnapshotListener { [weak self] snapshot, _ in
|
|
||||||
guard let snapshot else { return }
|
|
||||||
Task { @MainActor in self?.applyRemoteShoppingItems(snapshot) }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyRemoteDishes(_ snapshot: QuerySnapshot) {
|
|
||||||
guard let context else { return }
|
|
||||||
withRemoteApplication {
|
|
||||||
let existing = (try? context.fetch(FetchDescriptor<Dish>())) ?? []
|
|
||||||
var byId = Dictionary(existing.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
|
|
||||||
|
|
||||||
for document in snapshot.documents where !document.metadata.hasPendingWrites {
|
|
||||||
guard let id = UUID(uuidString: document.documentID) else { continue }
|
|
||||||
let data = document.data()
|
|
||||||
|
|
||||||
if data["deletedAt"] != nil {
|
|
||||||
if let dish = byId[id] { context.delete(dish) }
|
|
||||||
shadow.remove(path: "dishes/\(document.documentID)")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
let dish = byId[id] ?? {
|
|
||||||
let created = Dish(id: id, name: data["name"] as? String ?? "")
|
|
||||||
context.insert(created)
|
|
||||||
byId[id] = created
|
|
||||||
return created
|
|
||||||
}()
|
|
||||||
HouseholdDocuments.apply(data, to: dish)
|
|
||||||
shadow.set(path: "dishes/\(document.documentID)",
|
|
||||||
fingerprint: HouseholdDocuments.fingerprint(HouseholdDocuments.fields(for: dish)))
|
|
||||||
}
|
|
||||||
try? context.save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyRemoteTags(_ snapshot: QuerySnapshot) {
|
|
||||||
guard let context else { return }
|
|
||||||
withRemoteApplication {
|
|
||||||
let existing = (try? context.fetch(FetchDescriptor<Tag>())) ?? []
|
|
||||||
var byId = Dictionary(existing.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
|
|
||||||
|
|
||||||
for document in snapshot.documents where !document.metadata.hasPendingWrites {
|
|
||||||
guard let id = UUID(uuidString: document.documentID) else { continue }
|
|
||||||
let data = document.data()
|
|
||||||
|
|
||||||
if data["deletedAt"] != nil {
|
|
||||||
if let tag = byId[id] { context.delete(tag) }
|
|
||||||
shadow.remove(path: "tags/\(document.documentID)")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
let tag = byId[id] ?? {
|
|
||||||
let created = Tag(id: id, name: data["name"] as? String ?? "", color: data["color"] as? String ?? "#FF8A65")
|
|
||||||
context.insert(created)
|
|
||||||
byId[id] = created
|
|
||||||
return created
|
|
||||||
}()
|
|
||||||
HouseholdDocuments.apply(data, to: tag)
|
|
||||||
shadow.set(path: "tags/\(document.documentID)",
|
|
||||||
fingerprint: HouseholdDocuments.fingerprint(HouseholdDocuments.fields(for: tag)))
|
|
||||||
}
|
|
||||||
try? context.save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyRemoteWeekPlans(_ snapshot: QuerySnapshot) {
|
|
||||||
guard let context else { return }
|
|
||||||
withRemoteApplication {
|
|
||||||
let existing = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
|
|
||||||
var byKey = Dictionary(
|
|
||||||
existing.map { (HouseholdDocuments.weekKey(for: $0.weekStartDate), $0) },
|
|
||||||
uniquingKeysWith: { first, _ in first }
|
|
||||||
)
|
|
||||||
|
|
||||||
for document in snapshot.documents where !document.metadata.hasPendingWrites {
|
|
||||||
let data = document.data()
|
|
||||||
let key = document.documentID
|
|
||||||
|
|
||||||
if data["deletedAt"] != nil {
|
|
||||||
if let plan = byKey[key] { context.delete(plan) }
|
|
||||||
shadow.remove(path: "weekPlans/\(key)")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let weekStart = (data["weekStartDate"] as? Double).map({ Date(timeIntervalSince1970: $0) }) else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
let plan = byKey[key] ?? {
|
|
||||||
let created = WeekPlan(weekStartDate: weekStart)
|
|
||||||
context.insert(created)
|
|
||||||
byKey[key] = created
|
|
||||||
return created
|
|
||||||
}()
|
|
||||||
HouseholdDocuments.apply(data, to: plan)
|
|
||||||
shadow.set(path: "weekPlans/\(key)",
|
|
||||||
fingerprint: HouseholdDocuments.fingerprint(HouseholdDocuments.fields(for: plan)))
|
|
||||||
}
|
|
||||||
try? context.save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyRemoteSlots(_ snapshot: QuerySnapshot, weekKey: String) {
|
|
||||||
guard let context else { return }
|
|
||||||
withRemoteApplication {
|
|
||||||
let plans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
|
|
||||||
guard let plan = plans.first(where: { HouseholdDocuments.weekKey(for: $0.weekStartDate) == weekKey }) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for document in snapshot.documents where !document.metadata.hasPendingWrites {
|
|
||||||
let data = document.data()
|
|
||||||
guard let dayOfWeek = data["dayOfWeek"] as? Int,
|
|
||||||
let mealType = data["mealType"] as? String else { continue }
|
|
||||||
|
|
||||||
let existing = plan.slotList.first { $0.dayOfWeek == dayOfWeek && $0.mealType == mealType }
|
|
||||||
|
|
||||||
if data["deletedAt"] != nil {
|
|
||||||
if let existing {
|
|
||||||
plan.slotList.removeAll { $0.id == existing.id }
|
|
||||||
context.delete(existing)
|
|
||||||
}
|
|
||||||
shadow.remove(path: "weekPlans/\(weekKey)/slots/\(document.documentID)")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
let slot = existing ?? {
|
|
||||||
let created = MealSlot(dayOfWeek: dayOfWeek, mealType: mealType)
|
|
||||||
created.weekPlan = plan
|
|
||||||
plan.slotList.append(created)
|
|
||||||
context.insert(created)
|
|
||||||
return created
|
|
||||||
}()
|
|
||||||
HouseholdDocuments.apply(data, to: slot)
|
|
||||||
shadow.set(path: "weekPlans/\(weekKey)/slots/\(document.documentID)",
|
|
||||||
fingerprint: HouseholdDocuments.fingerprint(HouseholdDocuments.fields(for: slot)))
|
|
||||||
}
|
|
||||||
plan.updatedAt = Date()
|
|
||||||
try? context.save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyRemoteShoppingItems(_ snapshot: QuerySnapshot) {
|
|
||||||
guard let context else { return }
|
|
||||||
withRemoteApplication {
|
|
||||||
let existing = (try? context.fetch(FetchDescriptor<ShoppingItem>())) ?? []
|
|
||||||
var byId = Dictionary(existing.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
|
|
||||||
|
|
||||||
for document in snapshot.documents where !document.metadata.hasPendingWrites {
|
|
||||||
guard let id = UUID(uuidString: document.documentID) else { continue }
|
|
||||||
let data = document.data()
|
|
||||||
|
|
||||||
if data["deletedAt"] != nil {
|
|
||||||
if let item = byId[id] { context.delete(item) }
|
|
||||||
shadow.remove(path: "shoppingItems/\(document.documentID)")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let weekStart = (data["weekStartDate"] as? Double).map({ Date(timeIntervalSince1970: $0) }) else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
let item = byId[id] ?? {
|
|
||||||
let created = ShoppingItem(id: id, weekStartDate: weekStart, title: data["title"] as? String ?? "")
|
|
||||||
context.insert(created)
|
|
||||||
byId[id] = created
|
|
||||||
return created
|
|
||||||
}()
|
|
||||||
HouseholdDocuments.apply(data, to: item)
|
|
||||||
shadow.set(path: "shoppingItems/\(document.documentID)",
|
|
||||||
fingerprint: HouseholdDocuments.fingerprint(HouseholdDocuments.fields(for: item)))
|
|
||||||
}
|
|
||||||
try? context.save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Suppresses the local-save trigger while remote changes are written, so
|
|
||||||
/// applying an incoming change doesn't bounce straight back as a push.
|
|
||||||
private func withRemoteApplication(_ body: () -> Void) {
|
|
||||||
isApplyingRemote = true
|
|
||||||
body()
|
|
||||||
isApplyingRemote = false
|
|
||||||
persistShadow()
|
|
||||||
lastSyncedAt = Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Local → remote
|
|
||||||
|
|
||||||
private func observeLocalSaves() {
|
|
||||||
saveObserver = NotificationCenter.default.addObserver(
|
|
||||||
forName: ModelContext.didSave,
|
|
||||||
object: nil,
|
|
||||||
queue: .main
|
|
||||||
) { [weak self] _ in
|
|
||||||
Task { @MainActor in
|
|
||||||
guard let self, !self.isApplyingRemote else { return }
|
|
||||||
self.schedulePush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Debounced: a single user action can save several times in a row.
|
|
||||||
private func schedulePush() {
|
|
||||||
pushTask?.cancel()
|
|
||||||
pushTask = Task { [weak self] in
|
|
||||||
try? await Task.sleep(nanoseconds: 800_000_000)
|
|
||||||
guard !Task.isCancelled else { return }
|
|
||||||
await self?.pushLocalChanges()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Uploads everything whose fingerprint drifted from the shadow, and
|
|
||||||
/// tombstones what disappeared locally.
|
|
||||||
func pushLocalChanges() async {
|
|
||||||
guard let context, let householdId, let uid = AuthService.shared.uid else { return }
|
|
||||||
isSyncing = true
|
|
||||||
defer { isSyncing = false }
|
|
||||||
|
|
||||||
var documents: [String: [String: Any]] = [:]
|
|
||||||
|
|
||||||
for dish in (try? context.fetch(FetchDescriptor<Dish>())) ?? [] {
|
|
||||||
documents["dishes/\(dish.id.uuidString)"] = HouseholdDocuments.fields(for: dish)
|
|
||||||
}
|
|
||||||
for tag in (try? context.fetch(FetchDescriptor<Tag>())) ?? [] {
|
|
||||||
documents["tags/\(tag.id.uuidString)"] = HouseholdDocuments.fields(for: tag)
|
|
||||||
}
|
|
||||||
for item in (try? context.fetch(FetchDescriptor<ShoppingItem>())) ?? [] {
|
|
||||||
documents["shoppingItems/\(item.id.uuidString)"] = HouseholdDocuments.fields(for: item)
|
|
||||||
}
|
|
||||||
for plan in (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? [] {
|
|
||||||
let key = HouseholdDocuments.weekKey(for: plan.weekStartDate)
|
|
||||||
documents["weekPlans/\(key)"] = HouseholdDocuments.fields(for: plan)
|
|
||||||
for slot in plan.slotList {
|
|
||||||
let slotId = HouseholdDocuments.slotId(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType)
|
|
||||||
documents["weekPlans/\(key)/slots/\(slotId)"] = HouseholdDocuments.fields(for: slot)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var operations: [(path: String, fields: [String: Any], fingerprint: String?)] = []
|
|
||||||
|
|
||||||
for (path, fields) in documents {
|
|
||||||
let fingerprint = HouseholdDocuments.fingerprint(fields)
|
|
||||||
guard shadow.fingerprint(for: path) != fingerprint else { continue }
|
|
||||||
var payload = fields
|
|
||||||
payload["updatedAt"] = FieldValue.serverTimestamp()
|
|
||||||
payload["updatedBy"] = uid
|
|
||||||
payload["deletedAt"] = FieldValue.delete()
|
|
||||||
operations.append((path, payload, fingerprint))
|
|
||||||
}
|
|
||||||
|
|
||||||
for path in shadow.paths where documents[path] == nil {
|
|
||||||
operations.append((path, [
|
|
||||||
"deletedAt": FieldValue.serverTimestamp(),
|
|
||||||
"updatedBy": uid
|
|
||||||
], nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !operations.isEmpty else { return }
|
|
||||||
|
|
||||||
do {
|
|
||||||
// Firestore caps a batch at 500 writes.
|
|
||||||
for chunk in operations.chunked(into: 400) {
|
|
||||||
let batch = db.batch()
|
|
||||||
for operation in chunk {
|
|
||||||
let reference = db.document("households/\(householdId)/\(operation.path)")
|
|
||||||
batch.setData(operation.fields, forDocument: reference, merge: true)
|
|
||||||
}
|
|
||||||
try await batch.commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
for operation in operations {
|
|
||||||
if let fingerprint = operation.fingerprint {
|
|
||||||
shadow.set(path: operation.path, fingerprint: fingerprint)
|
|
||||||
} else {
|
|
||||||
shadow.remove(path: operation.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
persistShadow()
|
|
||||||
lastSyncedAt = Date()
|
|
||||||
lastError = nil
|
|
||||||
} catch {
|
|
||||||
lastError = error.localizedDescription
|
|
||||||
CrashlyticsService.record(error, context: "household_push")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Joining and leaving
|
|
||||||
|
|
||||||
/// Uploads the whole local store into a freshly created household.
|
|
||||||
func seedNewHousehold() async {
|
|
||||||
shadow.reset()
|
|
||||||
await pushLocalChanges()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wipes the local synced content so the household's own content can take
|
|
||||||
/// its place. Used when someone joins and chooses not to bring their data.
|
|
||||||
func replaceLocalContent(context: ModelContext) {
|
|
||||||
withRemoteApplication {
|
|
||||||
for plan in (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? [] {
|
|
||||||
for slot in plan.slotList { context.delete(slot) }
|
|
||||||
context.delete(plan)
|
|
||||||
}
|
|
||||||
for dish in (try? context.fetch(FetchDescriptor<Dish>())) ?? [] { context.delete(dish) }
|
|
||||||
for tag in (try? context.fetch(FetchDescriptor<Tag>())) ?? [] { context.delete(tag) }
|
|
||||||
for item in (try? context.fetch(FetchDescriptor<ShoppingItem>())) ?? [] { context.delete(item) }
|
|
||||||
try? context.save()
|
|
||||||
}
|
|
||||||
shadow.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func persistShadow() {
|
|
||||||
guard let householdId else { return }
|
|
||||||
shadow.save(householdId: householdId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Shadow
|
|
||||||
|
|
||||||
/// Last synced fingerprint per document path, persisted so a relaunch doesn't
|
|
||||||
/// re-upload the entire store.
|
|
||||||
struct SyncShadow {
|
|
||||||
private var fingerprints: [String: String] = [:]
|
|
||||||
|
|
||||||
var paths: [String] { Array(fingerprints.keys) }
|
|
||||||
|
|
||||||
func fingerprint(for path: String) -> String? { fingerprints[path] }
|
|
||||||
|
|
||||||
mutating func set(path: String, fingerprint: String) { fingerprints[path] = fingerprint }
|
|
||||||
|
|
||||||
mutating func remove(path: String) { fingerprints.removeValue(forKey: path) }
|
|
||||||
|
|
||||||
mutating func reset() { fingerprints = [:] }
|
|
||||||
|
|
||||||
private static func key(_ householdId: String) -> String { "household_shadow_\(householdId)" }
|
|
||||||
|
|
||||||
mutating func load(householdId: String) {
|
|
||||||
fingerprints = UserDefaults.standard.dictionary(forKey: Self.key(householdId)) as? [String: String] ?? [:]
|
|
||||||
}
|
|
||||||
|
|
||||||
func save(householdId: String) {
|
|
||||||
UserDefaults.standard.set(fingerprints, forKey: Self.key(householdId))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Array {
|
|
||||||
func chunked(into size: Int) -> [[Element]] {
|
|
||||||
guard size > 0 else { return [self] }
|
|
||||||
return stride(from: 0, to: count, by: size).map {
|
|
||||||
Array(self[$0..<Swift.min($0 + size, count)])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -249,7 +249,6 @@ struct HomeView: View {
|
|||||||
guard let settings = settings,
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,7 +794,6 @@ 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 {
|
||||||
|
|||||||
@@ -1,309 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import SwiftData
|
|
||||||
import AuthenticationServices
|
|
||||||
|
|
||||||
/// Household screen: sign in, create or join a household, see who is in it and
|
|
||||||
/// leave. The content sync itself runs in `HouseholdSyncService`.
|
|
||||||
struct HouseholdView: View {
|
|
||||||
@Environment(\.modelContext) private var context
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
@Query private var allSettings: [AppSettings]
|
|
||||||
|
|
||||||
@StateObject private var auth = AuthService.shared
|
|
||||||
@StateObject private var households = HouseholdService.shared
|
|
||||||
@StateObject private var sync = HouseholdSyncService.shared
|
|
||||||
|
|
||||||
@State private var householdName: String = ""
|
|
||||||
@State private var joinCode: String = ""
|
|
||||||
@State private var isWorking = false
|
|
||||||
@State private var errorMessage: String?
|
|
||||||
@State private var showJoinChoice = false
|
|
||||||
@State private var showLeaveConfirm = false
|
|
||||||
@State private var showPaywall = false
|
|
||||||
|
|
||||||
private var settings: AppSettings? { allSettings.first }
|
|
||||||
private var isPremium: Bool { settings?.isPremium ?? false }
|
|
||||||
private var isInHousehold: Bool { HouseholdRuntime.householdId != nil }
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
if HouseholdRuntime.needsRelaunch {
|
|
||||||
Section {
|
|
||||||
Label("household_relaunch_needed", systemImage: "arrow.clockwise.circle.fill")
|
|
||||||
.font(.mealMoodSmall)
|
|
||||||
.foregroundColor(.mealMoodWarning)
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.mealMoodSurface)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !auth.isSignedIn {
|
|
||||||
signInSection
|
|
||||||
} else if isInHousehold {
|
|
||||||
householdSection
|
|
||||||
membersSection
|
|
||||||
inviteSection
|
|
||||||
leaveSection
|
|
||||||
} else {
|
|
||||||
createSection
|
|
||||||
joinSection
|
|
||||||
}
|
|
||||||
|
|
||||||
if let errorMessage {
|
|
||||||
Section {
|
|
||||||
Text(errorMessage)
|
|
||||||
.font(.mealMoodCaption)
|
|
||||||
.foregroundColor(.mealMoodWarning)
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.mealMoodSurface)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.background(Color.mealMoodBackground.ignoresSafeArea())
|
|
||||||
.tint(.mealMoodCoral)
|
|
||||||
.navigationTitle("household_title")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.disabled(isWorking)
|
|
||||||
.onAppear {
|
|
||||||
households.start()
|
|
||||||
AnalyticsService.logScreenView("Household")
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showPaywall) {
|
|
||||||
if let settings {
|
|
||||||
NavigationStack {
|
|
||||||
PremiumView(settings: settings, source: "household")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.confirmationDialog("household_join_choice_title", isPresented: $showJoinChoice, titleVisibility: .visible) {
|
|
||||||
Button("household_join_choice_merge") { join(bringingLocalContent: true) }
|
|
||||||
Button("household_join_choice_replace", role: .destructive) { join(bringingLocalContent: false) }
|
|
||||||
Button("reset_cancel", role: .cancel) {}
|
|
||||||
} message: {
|
|
||||||
Text("household_join_choice_message")
|
|
||||||
}
|
|
||||||
.alert("household_leave_confirm_title", isPresented: $showLeaveConfirm) {
|
|
||||||
Button("reset_cancel", role: .cancel) {}
|
|
||||||
Button("household_leave_confirm_confirm", role: .destructive) { leave() }
|
|
||||||
} message: {
|
|
||||||
Text("household_leave_confirm_message")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Sections
|
|
||||||
|
|
||||||
private var signInSection: some View {
|
|
||||||
Section {
|
|
||||||
Text("household_intro")
|
|
||||||
.font(.mealMoodCaption)
|
|
||||||
.foregroundColor(.mealMoodTextSecondary)
|
|
||||||
|
|
||||||
SignInWithAppleButton(.signIn) { request in
|
|
||||||
auth.prepare(request: request)
|
|
||||||
} onCompletion: { result in
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await auth.completeSignInWithApple(result)
|
|
||||||
errorMessage = nil
|
|
||||||
} catch {
|
|
||||||
errorMessage = error.localizedDescription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.signInWithAppleButtonStyle(.black)
|
|
||||||
.frame(height: 46)
|
|
||||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
|
||||||
} footer: {
|
|
||||||
Text("household_sign_in_footer")
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.mealMoodSurface)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var createSection: some View {
|
|
||||||
Section {
|
|
||||||
TextField("household_name_placeholder", text: $householdName)
|
|
||||||
Button {
|
|
||||||
guard isPremium else {
|
|
||||||
showPaywall = true
|
|
||||||
AnalyticsService.logEvent("paywall_viewed", parameters: ["source": "household_create"])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
create()
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Label("household_create", systemImage: "house.fill")
|
|
||||||
if !isPremium {
|
|
||||||
Spacer()
|
|
||||||
Image(systemName: "star.circle.fill").foregroundColor(.mealMoodCoral)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(householdName.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
||||||
} header: {
|
|
||||||
Text("household_create_section")
|
|
||||||
} footer: {
|
|
||||||
if isPremium {
|
|
||||||
Text("household_create_footer")
|
|
||||||
} else {
|
|
||||||
Text("household_create_premium_footer")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.mealMoodSurface)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var joinSection: some View {
|
|
||||||
Section {
|
|
||||||
TextField("household_code_placeholder", text: $joinCode)
|
|
||||||
.textInputAutocapitalization(.characters)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
Button {
|
|
||||||
showJoinChoice = true
|
|
||||||
} label: {
|
|
||||||
Label("household_join", systemImage: "person.badge.plus")
|
|
||||||
}
|
|
||||||
.disabled(!HouseholdService.isPlausibleCode(joinCode))
|
|
||||||
} header: {
|
|
||||||
Text("household_join_section")
|
|
||||||
} footer: {
|
|
||||||
Text("household_join_footer")
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.mealMoodSurface)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var householdSection: some View {
|
|
||||||
Section {
|
|
||||||
HStack {
|
|
||||||
Label(households.household?.name ?? HouseholdRuntime.householdName ?? "", systemImage: "house.fill")
|
|
||||||
Spacer()
|
|
||||||
if sync.isSyncing {
|
|
||||||
ProgressView()
|
|
||||||
} else if let lastSyncedAt = sync.lastSyncedAt {
|
|
||||||
Text(lastSyncedAt.formatted(date: .omitted, time: .shortened))
|
|
||||||
.font(.mealMoodCaption)
|
|
||||||
.foregroundColor(.mealMoodTextSecondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let syncError = sync.lastError {
|
|
||||||
Text(syncError)
|
|
||||||
.font(.mealMoodCaption)
|
|
||||||
.foregroundColor(.mealMoodWarning)
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("household_section")
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.mealMoodSurface)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var membersSection: some View {
|
|
||||||
Section {
|
|
||||||
ForEach(households.members) { member in
|
|
||||||
HStack {
|
|
||||||
Label(
|
|
||||||
member.displayName.isEmpty ? String(localized: "household_member_unnamed") : member.displayName,
|
|
||||||
systemImage: member.role == "owner" ? "crown.fill" : "person.fill"
|
|
||||||
)
|
|
||||||
Spacer()
|
|
||||||
if member.id == auth.uid {
|
|
||||||
Text("household_member_you")
|
|
||||||
.font(.mealMoodCaption)
|
|
||||||
.foregroundColor(.mealMoodTextSecondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("household_members_section")
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.mealMoodSurface)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var inviteSection: some View {
|
|
||||||
Section {
|
|
||||||
if let code = households.household?.inviteCode {
|
|
||||||
HStack {
|
|
||||||
Text(code)
|
|
||||||
.font(.system(.title3, design: .monospaced))
|
|
||||||
.fontWeight(.bold)
|
|
||||||
.foregroundColor(.mealMoodTextPrimary)
|
|
||||||
Spacer()
|
|
||||||
ShareLink(item: String(format: String(localized: "household_invite_share"), code)) {
|
|
||||||
Image(systemName: "square.and.arrow.up")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let expiry = households.household?.inviteExpiresAt {
|
|
||||||
Text(String(format: String(localized: "household_invite_expires"),
|
|
||||||
expiry.formatted(date: .abbreviated, time: .omitted)))
|
|
||||||
.font(.mealMoodCaption)
|
|
||||||
.foregroundColor(.mealMoodTextSecondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button {
|
|
||||||
regenerateCode()
|
|
||||||
} label: {
|
|
||||||
Label("household_invite_regenerate", systemImage: "arrow.triangle.2.circlepath")
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("household_invite_section")
|
|
||||||
} footer: {
|
|
||||||
Text("household_invite_footer")
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.mealMoodSurface)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var leaveSection: some View {
|
|
||||||
Section {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
showLeaveConfirm = true
|
|
||||||
} label: {
|
|
||||||
Label("household_leave", systemImage: "rectangle.portrait.and.arrow.right")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.mealMoodSurface)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Actions
|
|
||||||
|
|
||||||
private func create() {
|
|
||||||
run {
|
|
||||||
let name = householdName.trimmingCharacters(in: .whitespaces)
|
|
||||||
_ = try await households.createHousehold(name: name, displayName: auth.displayName ?? "")
|
|
||||||
// Everything already planned on this device becomes the household's
|
|
||||||
// starting point.
|
|
||||||
await sync.seedNewHousehold()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func join(bringingLocalContent: Bool) {
|
|
||||||
run {
|
|
||||||
_ = try await households.join(code: joinCode, displayName: auth.displayName ?? "")
|
|
||||||
if !bringingLocalContent {
|
|
||||||
sync.replaceLocalContent(context: context)
|
|
||||||
}
|
|
||||||
joinCode = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func leave() {
|
|
||||||
run {
|
|
||||||
try await households.leaveHousehold()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func regenerateCode() {
|
|
||||||
run {
|
|
||||||
try await households.regenerateInviteCode()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func run(_ operation: @escaping () async throws -> Void) {
|
|
||||||
isWorking = true
|
|
||||||
errorMessage = nil
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await operation()
|
|
||||||
} catch {
|
|
||||||
errorMessage = error.localizedDescription
|
|
||||||
CrashlyticsService.record(error, context: "household_action")
|
|
||||||
}
|
|
||||||
isWorking = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -227,26 +227,6 @@ struct SettingsView: View {
|
|||||||
.listRowBackground(Color.mealMoodSurface)
|
.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()) {
|
||||||
|
|||||||
@@ -1,241 +0,0 @@
|
|||||||
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)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
rules_version = '2';
|
|
||||||
|
|
||||||
// Hogar compartido de MealMood (issue #33). Ver docs/household-sync.md.
|
|
||||||
//
|
|
||||||
// Regla de oro: el contenido de un hogar solo lo ven y lo escriben sus
|
|
||||||
// miembros. Lo único legible desde fuera es un código de invitación concreto,
|
|
||||||
// y solo por id — nunca listando la colección.
|
|
||||||
service cloud.firestore {
|
|
||||||
match /databases/{database}/documents {
|
|
||||||
|
|
||||||
function signedIn() {
|
|
||||||
return request.auth != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function uid() {
|
|
||||||
return request.auth.uid;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMember(householdId) {
|
|
||||||
return signedIn()
|
|
||||||
&& uid() in get(/databases/$(database)/documents/households/$(householdId)).data.memberIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perfil mínimo: a qué hogar pertenece cada persona y su nombre visible.
|
|
||||||
match /users/{userId} {
|
|
||||||
allow read, write: if signedIn() && uid() == userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Códigos de invitación. `get` abierto a cualquier persona autenticada —
|
|
||||||
// es la única forma de resolver un código antes de ser miembro. `list`
|
|
||||||
// queda prohibido para que nadie pueda barrer códigos ajenos.
|
|
||||||
match /invites/{code} {
|
|
||||||
allow get: if signedIn();
|
|
||||||
allow list: if false;
|
|
||||||
allow create, delete: if signedIn()
|
|
||||||
&& request.resource.data.createdBy == uid();
|
|
||||||
allow update: if false;
|
|
||||||
}
|
|
||||||
|
|
||||||
match /households/{householdId} {
|
|
||||||
allow get: if isMember(householdId);
|
|
||||||
allow list: if false;
|
|
||||||
|
|
||||||
// Al crear, la persona queda como dueña y único miembro.
|
|
||||||
allow create: if signedIn()
|
|
||||||
&& request.resource.data.ownerId == uid()
|
|
||||||
&& request.resource.data.memberIds == [uid()];
|
|
||||||
|
|
||||||
// Dos actualizaciones legítimas:
|
|
||||||
// - un miembro cambia datos del hogar (nombre, código de invitación),
|
|
||||||
// - alguien de fuera se añade a sí mismo con un código válido.
|
|
||||||
allow update: if isMember(householdId) || joiningWithValidInvite(householdId);
|
|
||||||
|
|
||||||
allow delete: if signedIn()
|
|
||||||
&& resource.data.ownerId == uid();
|
|
||||||
|
|
||||||
function joiningWithValidInvite(householdId) {
|
|
||||||
let code = request.resource.data.lastJoinCode;
|
|
||||||
let invitePath = /databases/$(database)/documents/invites/$(code);
|
|
||||||
return signedIn()
|
|
||||||
&& exists(invitePath)
|
|
||||||
&& get(invitePath).data.householdId == householdId
|
|
||||||
&& get(invitePath).data.expiresAt > request.time
|
|
||||||
// Solo puede añadirse a sí misma, y sin tocar a nadie más.
|
|
||||||
&& request.resource.data.memberIds == resource.data.memberIds.concat([uid()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
match /members/{memberId} {
|
|
||||||
allow read: if isMember(householdId);
|
|
||||||
// Cada persona escribe su propia ficha; nadie edita la de otro.
|
|
||||||
allow write: if signedIn() && uid() == memberId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contenido del hogar: platos, etiquetas, semanas, slots y compra.
|
|
||||||
match /dishes/{dishId} {
|
|
||||||
allow read, write: if isMember(householdId);
|
|
||||||
}
|
|
||||||
|
|
||||||
match /tags/{tagId} {
|
|
||||||
allow read, write: if isMember(householdId);
|
|
||||||
}
|
|
||||||
|
|
||||||
match /weekPlans/{weekKey} {
|
|
||||||
allow read, write: if isMember(householdId);
|
|
||||||
|
|
||||||
match /slots/{slotId} {
|
|
||||||
allow read, write: if isMember(householdId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match /shoppingItems/{itemId} {
|
|
||||||
allow read, write: if isMember(householdId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user