diff --git a/MealMood.xcodeproj/project.pbxproj b/MealMood.xcodeproj/project.pbxproj index e49a874..33809f8 100644 --- a/MealMood.xcodeproj/project.pbxproj +++ b/MealMood.xcodeproj/project.pbxproj @@ -13,9 +13,11 @@ 0F91C8611D3BBE1FBD51D1E6 /* MealSlot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797A41D292142240FBDF9F2A /* MealSlot.swift */; }; 17E1054EE183486E7BE38C19 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6FC2153490D53B80DBDC034 /* WidgetDataStore.swift */; }; 19330C6ECE7A60110A3A679D /* TagListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB00DA865F6FE86952BFC3 /* TagListView.swift */; }; + 1E99F323E0A8AC11DD733CB5 /* HouseholdSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79D8CD7D36201B9AB1A901E4 /* HouseholdSyncService.swift */; }; 203DDFF14C0688DEDB6EE83E /* Localization+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0164A33496A304E40685EA6D /* Localization+Helpers.swift */; }; 2124FD777418A4EEDF64BDC3 /* WeekReadyStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D954415A7AC73D41309D2BDF /* WeekReadyStepView.swift */; }; 233B9C1A5CA7E938318491C0 /* SocialShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1975C49FFC7D91AEB7932C97 /* SocialShareSheet.swift */; }; + 241F22801CB94AE667BF184B /* HouseholdDocumentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470F235FE7288C1096B834C0 /* HouseholdDocumentsTests.swift */; }; 2519A55C6BDA686B6F85E79D /* View+MealCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC01EE9D78D980727A644CDF /* View+MealCard.swift */; }; 25B3C328E088A403CABAF20A /* PremiumSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 928E6D4F6D78AD1840245FB4 /* PremiumSyncService.swift */; }; 27D256A032E69F397E9552F3 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C2D1AAC0DB21EA5E2CCA38AD /* SwiftUI.framework */; }; @@ -24,13 +26,16 @@ 30540EF74F2C97F741BA6A1A /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049AE68D5B577514ADD6BACB /* Tag.swift */; }; 31DFC035AE34F49FB35071A7 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C47DD7B8F4B1441780E611 /* HomeView.swift */; }; 37F5CEC4862C0AE2F3385ABA /* DishListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB3BD0E078644749E93B0D2B /* DishListView.swift */; }; + 383442C406924A5858FA8016 /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 5FEFD3B35109089DC5ED252E /* FirebaseFirestore */; }; 3ACF9F301D6AC8A9A0B1AC3E /* MealMoodWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 2CF3C9FF75E9F24B32D54D5A /* MealMoodWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3C5225918DA8DE77F5AC1AA0 /* MealMoodApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C20D9FC522FEBF7206CFD32 /* MealMoodApp.swift */; }; + 3D83C33A78CACDB74989BE95 /* HouseholdService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3933965A7C5A18B7BE6DE43D /* HouseholdService.swift */; }; 4218F220B7412965A5001A88 /* CalendarStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76719058DD3A4177B2B4163E /* CalendarStepView.swift */; }; 443CC3997A9A4818B51066FB /* StatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8AD94FC874467D863D464C /* StatsView.swift */; }; 44E43D5F00D1B513F9BABE80 /* ShoppingListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C512046B1410E236A3F6286 /* ShoppingListService.swift */; }; 478AE18DA0F887D3D5164635 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C77C350D4A839B76A3D2EFE7 /* Foundation.framework */; }; 48F9D3308375F820B2B88D1B /* PremiumUpsellBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CFA001E1BFA916ABC4C44C1 /* PremiumUpsellBanner.swift */; }; + 4D4724E1F36483B1D22273D8 /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 4260DAE0625E6C7D6DDDD153 /* FirebaseAuth */; }; 4D53B5101AE845D7C03A0EB4 /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = 192E7CF40FD34C3FF4DDFDA3 /* GoogleMobileAds */; }; 511DDB82FF4DD30018101418 /* DishDrawerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23957C735075B0DFB1DF51D7 /* DishDrawerView.swift */; }; 519AA84E7D3B9ED5FC215E4B /* MealMoodWatchWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A43C4A6B38F731B9FE6AACB /* MealMoodWatchWidget.swift */; }; @@ -56,13 +61,16 @@ 7E163CDBF0832188EFA31C7D /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F46C32F97EAD52F8F3BC0B7 /* SettingsView.swift */; }; 861557A13918B99C421251E0 /* PremiumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324D77FEC4C8C371695F27CC /* PremiumView.swift */; }; 867A511F154A33E9DD7FE9BC /* CalendarService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A631C6D0CF7A0724ACF6A5 /* CalendarService.swift */; }; + 87E31CDE1CCE4802FF7E2EC4 /* HouseholdView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20BF9DA2DD0717F66A3A574 /* HouseholdView.swift */; }; 87EB371FD3B6C241AC721C55 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 8E15828872FAEA71A7384099 /* Localizable.strings */; }; + 880406FE20DDF465670CF6F3 /* HouseholdRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 444001767E7BAABE7560475B /* HouseholdRuntime.swift */; }; 8B12FAE3CC07AFB284094C61 /* WeekSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27337A07289450A1D494AC02 /* WeekSchedule.swift */; }; 8B4672C76B05BC1DFB5C979D /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5018BA4B3DC94CA7443CA9D1 /* TodayView.swift */; }; 8CEAC8993D33BFBBEB5319A8 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EE10285FEE9BE0684CC66A3 /* NotificationService.swift */; }; 8DB624B469178CFD8CD87672 /* OnboardingFlowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBD59AC26D5C6D138D67E35 /* OnboardingFlowUITests.swift */; }; 8F739A4496501009B8D829B3 /* Color+MealMood.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB771E85924AE086647A5113 /* Color+MealMood.swift */; }; 962BC821D22DE718E846BD64 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0614F890C8CA1620F8CF331 /* Foundation.framework */; }; + 99B404198B12B5CCC9461AA5 /* HouseholdUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD59493349E71608861A6BA7 /* HouseholdUITests.swift */; }; 9BC60C30918C117608E43699 /* DefaultDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E00AB23342224B3407838B /* DefaultDataService.swift */; }; 9BE86765B8F0368AD5C86FC4 /* TagViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9BFFE7A4BC6961815D2334 /* TagViewModel.swift */; }; 9D40600B9AC21F0FF64B77F9 /* StoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 103374A7D91B4F884278DA72 /* StoreManager.swift */; }; @@ -84,6 +92,7 @@ B4D6F8012C3E5F7A91B3D5F7 /* FeedbackStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */; }; B8D7CA62789429C2F693E4F6 /* MealMoodLogoMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC64110469AA91D5F28C423 /* MealMoodLogoMark.swift */; }; B8FDF31D3BD7DD7220CE21A3 /* ShoppingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC93C653B5540D7205BF294 /* ShoppingItem.swift */; }; + BA965DE97C273AC2AA7AD92F /* HouseholdDocuments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 288CAA2EC068786039E2D719 /* HouseholdDocuments.swift */; }; BE908B23F64F33D594AB8D82 /* DateHelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD33453B7DF86EDBAEB80F1 /* DateHelpersTests.swift */; }; BF89CD5E8CA0DFFC224CF94D /* WatchWeekPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67CBDF2D2972139881AA59B1 /* WatchWeekPayload.swift */; }; C14BDE4C3EF6AF75F378D892 /* PrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA97AD958C922D8AFF73314 /* PrimaryButton.swift */; }; @@ -109,6 +118,7 @@ E3BD64E6D32ED7052F174923 /* TagSelectorSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CF1C9CC9BDB3B20B1834EB /* TagSelectorSheet.swift */; }; E5D9C4B783A14E2B9F4D2E68 /* PaywallStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F61A8C92B47D5938C61E9A75 /* PaywallStepView.swift */; }; E6A57F29C02AE138D705BAF7 /* AppIconPlaceholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00F2EE5A756C183B6D505403 /* AppIconPlaceholder.swift */; }; + E79AA07F325241DDD03C9557 /* AuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 566384A35602C58EE062DB06 /* AuthService.swift */; }; E8528D9162976DBEDCEF1822 /* MealMoodWatchWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 6CFA7D73F403353774761AD1 /* MealMoodWatchWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; EA4EC6244EEBE6D568BCA5E5 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B3944B4AD876455057EB2265 /* Assets.xcassets */; }; F65AD80426AF8DA12FBD661C /* WeekendsStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035C6C0B728EB0EA80394CDD /* WeekendsStepView.swift */; }; @@ -208,18 +218,23 @@ 212FF80B5B9E9E5243CA79D5 /* MealMoodUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MealMoodUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 23957C735075B0DFB1DF51D7 /* DishDrawerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DishDrawerView.swift; sourceTree = ""; }; 27337A07289450A1D494AC02 /* WeekSchedule.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WeekSchedule.swift; sourceTree = ""; }; + 288CAA2EC068786039E2D719 /* HouseholdDocuments.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HouseholdDocuments.swift; sourceTree = ""; }; 2C20D9FC522FEBF7206CFD32 /* MealMoodApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealMoodApp.swift; sourceTree = ""; }; 2C5CD466A50C339519544347 /* TagRulesEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagRulesEditView.swift; sourceTree = ""; }; 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 = ""; }; 324D77FEC4C8C371695F27CC /* PremiumView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumView.swift; sourceTree = ""; }; + 3933965A7C5A18B7BE6DE43D /* HouseholdService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HouseholdService.swift; sourceTree = ""; }; 3CBD59AC26D5C6D138D67E35 /* OnboardingFlowUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OnboardingFlowUITests.swift; sourceTree = ""; }; 3FC93C653B5540D7205BF294 /* ShoppingItem.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShoppingItem.swift; sourceTree = ""; }; + 444001767E7BAABE7560475B /* HouseholdRuntime.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HouseholdRuntime.swift; sourceTree = ""; }; 449577ECE261177D4A1C1572 /* WatchSyncService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSyncService.swift; sourceTree = ""; }; + 470F235FE7288C1096B834C0 /* HouseholdDocumentsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HouseholdDocumentsTests.swift; sourceTree = ""; }; 4EDEE086FD20D16DE507B247 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; 5018BA4B3DC94CA7443CA9D1 /* TodayView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = ""; }; 50E1C12C3A693D7C41E9F80B /* TagPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagPill.swift; sourceTree = ""; }; 5187023D83BBC5E8D304CE3A /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; + 566384A35602C58EE062DB06 /* AuthService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = ""; }; 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 = ""; }; 5C512046B1410E236A3F6286 /* ShoppingListService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShoppingListService.swift; sourceTree = ""; }; @@ -242,6 +257,7 @@ 7603187713D390BD4D35770C /* AutocompleteEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteEngineTests.swift; sourceTree = ""; }; 76719058DD3A4177B2B4163E /* CalendarStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarStepView.swift; sourceTree = ""; }; 797A41D292142240FBDF9F2A /* MealSlot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealSlot.swift; sourceTree = ""; }; + 79D8CD7D36201B9AB1A901E4 /* HouseholdSyncService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HouseholdSyncService.swift; sourceTree = ""; }; 7DE70CD2A01FCE5370A46DD3 /* Date+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Helpers.swift"; sourceTree = ""; }; 812303C94C8C1D2FADF3CB37 /* AdMobConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdMobConfig.swift; sourceTree = ""; }; 8411FF2737FDEF48170F9634 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -258,10 +274,12 @@ A1B2C3D4E5F60718293A4B02 /* IngredientParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IngredientParser.swift; sourceTree = ""; }; A1B2C3D4E5F60718293A4B04 /* SpeechDictationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SpeechDictationService.swift; sourceTree = ""; }; A1C3CBAFE191AD6643F861B7 /* DeduplicationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeduplicationService.swift; sourceTree = ""; }; + A20BF9DA2DD0717F66A3A574 /* HouseholdView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HouseholdView.swift; sourceTree = ""; }; A23267BAA116E1214870416C /* WeekPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekPlan.swift; sourceTree = ""; }; A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackStore.swift; sourceTree = ""; }; A6FC2153490D53B80DBDC034 /* WidgetDataStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = ""; }; AB771E85924AE086647A5113 /* Color+MealMood.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+MealMood.swift"; sourceTree = ""; }; + AD59493349E71608861A6BA7 /* HouseholdUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HouseholdUITests.swift; sourceTree = ""; }; B042BB2F81973D854E84BABD /* MealMoodWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MealMoodWidget.swift; sourceTree = ""; }; B0A631C6D0CF7A0724ACF6A5 /* CalendarService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarService.swift; sourceTree = ""; }; B3369706BF941E09F88243F1 /* MealMoodWidget.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = MealMoodWidget.entitlements; sourceTree = ""; }; @@ -344,6 +362,8 @@ FB0011111111111111111101 /* FirebaseAnalytics in Frameworks */, FB0011111111111111111108 /* FirebaseCrashlytics in Frameworks */, C3CB4ACC213DAAD25E610C56 /* WidgetKit.framework in Frameworks */, + 4D4724E1F36483B1D22273D8 /* FirebaseAuth in Frameworks */, + 383442C406924A5858FA8016 /* FirebaseFirestore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -411,6 +431,19 @@ path = Premium; sourceTree = ""; }; + 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 = ""; + }; 4B4ACE1D0F2968B284A1FA7E /* Configuration */ = { isa = PBXGroup; children = ( @@ -423,6 +456,7 @@ isa = PBXGroup; children = ( 0F46C32F97EAD52F8F3BC0B7 /* SettingsView.swift */, + A20BF9DA2DD0717F66A3A574 /* HouseholdView.swift */, ); path = Settings; sourceTree = ""; @@ -507,6 +541,7 @@ 62E2F6B2694CEC97AE3C1B13 /* PremiumSyncServiceTests.swift */, 69C0BDFEBA8B692530CB6B5E /* SpeechDictationServiceTests.swift */, 91C2BCF9D7A1458E045DA053 /* WeekScheduleTests.swift */, + 470F235FE7288C1096B834C0 /* HouseholdDocumentsTests.swift */, ); path = MealMoodTests; sourceTree = ""; @@ -596,6 +631,7 @@ children = ( 3CBD59AC26D5C6D138D67E35 /* OnboardingFlowUITests.swift */, 6FCDE0D7D1609EC0DB3DD134 /* WeekScheduleUITests.swift */, + AD59493349E71608861A6BA7 /* HouseholdUITests.swift */, ); name = MealMoodUITests; path = MealMoodUITests; @@ -693,6 +729,7 @@ A1C3CBAFE191AD6643F861B7 /* DeduplicationService.swift */, 67CBDF2D2972139881AA59B1 /* WatchWeekPayload.swift */, 449577ECE261177D4A1C1572 /* WatchSyncService.swift */, + 3FAA36B72672FA8DFD76A98E /* Household */, ); path = Services; sourceTree = ""; @@ -790,6 +827,8 @@ 192E7CF40FD34C3FF4DDFDA3 /* GoogleMobileAds */, FB0011111111111111111105 /* FirebaseAnalytics */, FB001111111111111111110B /* FirebaseCrashlytics */, + 4260DAE0625E6C7D6DDDD153 /* FirebaseAuth */, + 5FEFD3B35109089DC5ED252E /* FirebaseFirestore */, ); productName = MealMood; productReference = C7B13F4D4D362E50CA65A0C1 /* MealMood.app */; @@ -930,6 +969,7 @@ files = ( 8DB624B469178CFD8CD87672 /* OnboardingFlowUITests.swift in Sources */, CC96D3416E044B750C99B625 /* WeekScheduleUITests.swift in Sources */, + 99B404198B12B5CCC9461AA5 /* HouseholdUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -973,6 +1013,7 @@ 2A58E7C96AF7C14EBFE1C65C /* PremiumSyncServiceTests.swift in Sources */, 64CC61B20F0AF602A6CC1114 /* SpeechDictationServiceTests.swift in Sources */, A569F5FED382E8EABC471416 /* WeekScheduleTests.swift in Sources */, + 241F22801CB94AE667BF184B /* HouseholdDocumentsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1055,6 +1096,12 @@ C6DBE6B5B4BE002A234CBCCA /* WatchSyncService.swift in Sources */, 8B12FAE3CC07AFB284094C61 /* WeekSchedule.swift in Sources */, 57F913037D2D381DFA1FB7DE /* WeekScheduleSheet.swift in Sources */, + 880406FE20DDF465670CF6F3 /* HouseholdRuntime.swift in Sources */, + E79AA07F325241DDD03C9557 /* AuthService.swift in Sources */, + 3D83C33A78CACDB74989BE95 /* HouseholdService.swift in Sources */, + BA965DE97C273AC2AA7AD92F /* HouseholdDocuments.swift in Sources */, + 1E99F323E0A8AC11DD733CB5 /* HouseholdSyncService.swift in Sources */, + 87E31CDE1CCE4802FF7E2EC4 /* HouseholdView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1586,6 +1633,16 @@ package = BE33D996A0D3FB7D5E000A7F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */; productName = GoogleMobileAds; }; + 4260DAE0625E6C7D6DDDD153 /* FirebaseAuth */ = { + isa = XCSwiftPackageProductDependency; + package = FB0011111111111111111104 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAuth; + }; + 5FEFD3B35109089DC5ED252E /* FirebaseFirestore */ = { + isa = XCSwiftPackageProductDependency; + package = FB0011111111111111111104 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseFirestore; + }; FB0011111111111111111105 /* FirebaseAnalytics */ = { isa = XCSwiftPackageProductDependency; package = FB0011111111111111111104 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; diff --git a/MealMood/ContentView.swift b/MealMood/ContentView.swift index 2295434..38294f3 100644 --- a/MealMood/ContentView.swift +++ b/MealMood/ContentView.swift @@ -33,6 +33,11 @@ struct ContentView: View { // Activate WatchConnectivity as early as possible so the first widget // refresh doesn't race the session activation. .onAppear { WatchSyncService.shared.activate() } + // 2.2: household content syncs through Firestore instead of CloudKit. + .onAppear { + HouseholdService.shared.start() + HouseholdSyncService.shared.start(context: context) + } // This is the most reliable way to catch a purchase even if the app was // interrupted during the payment flow. .task { diff --git a/MealMood/MealMoodApp.swift b/MealMood/MealMoodApp.swift index 52918ea..d9e40b4 100644 --- a/MealMood/MealMoodApp.swift +++ b/MealMood/MealMoodApp.swift @@ -49,17 +49,25 @@ struct MealMoodApp: App { ShoppingItem.self ]) + // 2.2: inside a household the data lives in Firestore (shared with the + // other members, reachable from Android) and the store stays local. + // Running CloudKit on top would mean two mirrors writing the same + // objects. See docs/household-sync.md. + HouseholdRuntime.isHouseholdStore = HouseholdRuntime.householdId != nil + // 2.0: CloudKit private-database sync (multi-device, same Apple ID). // Falls back to the local-only store when CloudKit isn't available // (signed-out iCloud, missing entitlement in dev builds, etc.). - let cloudConfiguration = ModelConfiguration(cloudKitDatabase: .automatic) - do { - let container = try ModelContainer(for: schema, configurations: [cloudConfiguration]) - CloudSyncRuntime.isCloudKitActive = true - return container - } catch { - CrashlyticsService.record(error, context: "cloudkit_container") - print("CloudKit container unavailable, using local store: \(error)") + if !HouseholdRuntime.isHouseholdStore { + let cloudConfiguration = ModelConfiguration(cloudKitDatabase: .automatic) + do { + let container = try ModelContainer(for: schema, configurations: [cloudConfiguration]) + CloudSyncRuntime.isCloudKitActive = true + return container + } catch { + CrashlyticsService.record(error, context: "cloudkit_container") + print("CloudKit container unavailable, using local store: \(error)") + } } let configuration = ModelConfiguration(cloudKitDatabase: .none) diff --git a/MealMood/Resources/MealMood.entitlements b/MealMood/Resources/MealMood.entitlements index e6b301f..fecaf00 100644 --- a/MealMood/Resources/MealMood.entitlements +++ b/MealMood/Resources/MealMood.entitlements @@ -14,6 +14,10 @@ com.apple.developer.ubiquity-kvstore-identifier $(TeamIdentifierPrefix)$(CFBundleIdentifier) + com.apple.developer.applesignin + + Default + com.apple.security.application-groups group.com.alexandrevazquez.mealmood diff --git a/MealMood/Resources/de.lproj/Localizable.strings b/MealMood/Resources/de.lproj/Localizable.strings index 84bc7ff..178141f 100644 --- a/MealMood/Resources/de.lproj/Localizable.strings +++ b/MealMood/Resources/de.lproj/Localizable.strings @@ -478,3 +478,40 @@ "week_schedule_warning_confirm" = "Trotzdem entfernen"; "toast_week_schedule_updated" = "Woche aktualisiert"; "toast_week_schedule_reset" = "Woche folgt wieder deinen Einstellungen"; + +/* Hogar compartido (2.2) */ +"household_title" = "Gemeinsamer Haushalt"; +"household_section" = "Haushalt"; +"household_settings_footer" = "Plant die Woche gemeinsam, jede Person mit eigenem Konto. Funktioniert auch unter Android."; +"household_intro" = "In einem Haushalt planen mehrere Personen dieselbe Woche und teilen sich Gerichte und Einkaufsliste."; +"household_sign_in_footer" = "Zum Teilen musst du dich anmelden. Deine Gerichte bleiben deine: außerhalb deines Haushalts sieht sie niemand."; +"household_create_section" = "Haushalt erstellen"; +"household_create" = "Haushalt erstellen"; +"household_name_placeholder" = "Name des Haushalts (z. B. Zuhause)"; +"household_create_footer" = "Deine aktuellen Gerichte, Tags und Wochen werden zum Ausgangspunkt des Haushalts."; +"household_create_premium_footer" = "Einen Haushalt zu erstellen ist Premium. Wer eingeladen wird, zahlt nichts."; +"household_join_section" = "Haushalt beitreten"; +"household_join" = "Mit Code beitreten"; +"household_code_placeholder" = "Einladungscode"; +"household_join_footer" = "Frag die Person, die den Haushalt erstellt hat, nach dem Code. Er läuft nach 7 Tagen ab."; +"household_join_choice_title" = "Was ist mit deinen Gerichten?"; +"household_join_choice_message" = "Du kannst deine Gerichte und Wochen in diesen Haushalt mitnehmen oder nur mit denen des Haushalts starten."; +"household_join_choice_merge" = "Meine Gerichte mitnehmen"; +"household_join_choice_replace" = "Nur die des Haushalts"; +"household_members_section" = "Mitglieder"; +"household_member_you" = "du"; +"household_member_unnamed" = "Ohne Namen"; +"household_invite_section" = "Einladung"; +"household_invite_footer" = "Teile diesen Code mit allen, die mit dir planen sollen."; +"household_invite_share" = "Komm in meinen MealMood-Haushalt mit dem Code %@ — https://mealmood.app"; +"household_invite_expires" = "Läuft ab am %@"; +"household_invite_regenerate" = "Neuen Code erzeugen"; +"household_leave" = "Haushalt verlassen"; +"household_leave_confirm_title" = "Haushalt verlassen?"; +"household_leave_confirm_message" = "Du siehst und bearbeitest die Gerichte und Wochen des Haushalts auf diesem Gerät nicht mehr."; +"household_leave_confirm_confirm" = "Verlassen"; +"household_relaunch_needed" = "Schließe MealMood und öffne es erneut, um den Wechsel abzuschließen."; +"household_error_not_signed_in" = "Melde dich an, bevor du einen Haushalt erstellst oder beitrittst."; +"household_error_no_household" = "Dieses Gerät gehört zu keinem Haushalt."; +"household_error_invalid_code" = "Diesen Code gibt es nicht."; +"household_error_expired_code" = "Dieser Code ist abgelaufen. Bitte um einen neuen."; diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index 2c18cbf..127842a 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -478,3 +478,40 @@ "week_schedule_warning_confirm" = "Remove anyway"; "toast_week_schedule_updated" = "Week updated"; "toast_week_schedule_reset" = "Week back to your general settings"; + +/* Hogar compartido (2.2) */ +"household_title" = "Shared household"; +"household_section" = "Household"; +"household_settings_footer" = "Plan the week together, each person from their own account. Works from Android too."; +"household_intro" = "A household lets several people plan the same week, sharing dishes and the shopping list."; +"household_sign_in_footer" = "Signing in is what makes sharing possible. Your dishes stay yours: nobody outside your household sees them."; +"household_create_section" = "Create a household"; +"household_create" = "Create household"; +"household_name_placeholder" = "Household name (e.g. Home)"; +"household_create_footer" = "Your current dishes, tags and weeks become the household's starting point."; +"household_create_premium_footer" = "Creating a household is Premium. Whoever you invite doesn't have to pay."; +"household_join_section" = "Join a household"; +"household_join" = "Join with code"; +"household_code_placeholder" = "Invite code"; +"household_join_footer" = "Ask whoever created the household for the code. It expires after 7 days."; +"household_join_choice_title" = "What about your dishes?"; +"household_join_choice_message" = "You can bring the dishes and weeks you already have into this household, or start with the household's own."; +"household_join_choice_merge" = "Bring my dishes"; +"household_join_choice_replace" = "Use the household's only"; +"household_members_section" = "Members"; +"household_member_you" = "you"; +"household_member_unnamed" = "Unnamed"; +"household_invite_section" = "Invitation"; +"household_invite_footer" = "Share this code with whoever you want planning with you."; +"household_invite_share" = "Join my MealMood household with the code %@ — https://mealmood.app"; +"household_invite_expires" = "Expires on %@"; +"household_invite_regenerate" = "Generate a new code"; +"household_leave" = "Leave household"; +"household_leave_confirm_title" = "Leave the household?"; +"household_leave_confirm_message" = "You'll stop seeing and editing the household's dishes and weeks on this device."; +"household_leave_confirm_confirm" = "Leave"; +"household_relaunch_needed" = "Quit and reopen MealMood to finish the change."; +"household_error_not_signed_in" = "Sign in before creating or joining a household."; +"household_error_no_household" = "This device doesn't belong to a household."; +"household_error_invalid_code" = "That code doesn't exist."; +"household_error_expired_code" = "That code has expired. Ask for a new one."; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index 1542b1e..788c9c9 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -478,3 +478,40 @@ "week_schedule_warning_confirm" = "Quitar igualmente"; "toast_week_schedule_updated" = "Semana actualizada"; "toast_week_schedule_reset" = "Semana con tu configuración general"; + +/* Hogar compartido (2.2) */ +"household_title" = "Hogar compartido"; +"household_section" = "Hogar"; +"household_settings_footer" = "Planificad la semana entre varias personas, cada una desde su cuenta. Funciona también desde Android."; +"household_intro" = "Un hogar permite que varias personas planifiquen la misma semana, con sus platos y su lista de la compra en común."; +"household_sign_in_footer" = "Necesitas identificarte para compartir con otra persona. Tus platos siguen siendo tuyos: nadie fuera de tu hogar los ve."; +"household_create_section" = "Crear un hogar"; +"household_create" = "Crear hogar"; +"household_name_placeholder" = "Nombre del hogar (p. ej. Casa)"; +"household_create_footer" = "Tus platos, etiquetas y semanas actuales pasan a ser el punto de partida del hogar."; +"household_create_premium_footer" = "Crear un hogar es Premium. Quien recibe la invitación no necesita pagar."; +"household_join_section" = "Unirme a un hogar"; +"household_join" = "Unirme con el código"; +"household_code_placeholder" = "Código de invitación"; +"household_join_footer" = "Pide el código a quien creó el hogar. Caduca a los 7 días."; +"household_join_choice_title" = "¿Qué hacemos con tus platos?"; +"household_join_choice_message" = "Puedes llevarte a este hogar los platos y semanas que ya tienes, o empezar solo con los del hogar."; +"household_join_choice_merge" = "Llevarme mis platos"; +"household_join_choice_replace" = "Usar solo los del hogar"; +"household_members_section" = "Miembros"; +"household_member_you" = "tú"; +"household_member_unnamed" = "Sin nombre"; +"household_invite_section" = "Invitación"; +"household_invite_footer" = "Comparte este código con quien quieras que planifique contigo."; +"household_invite_share" = "Únete a mi hogar en MealMood con el código %@ — https://mealmood.app"; +"household_invite_expires" = "Caduca el %@"; +"household_invite_regenerate" = "Generar código nuevo"; +"household_leave" = "Salir del hogar"; +"household_leave_confirm_title" = "¿Salir del hogar?"; +"household_leave_confirm_message" = "Dejarás de ver y de editar los platos y las semanas del hogar en este dispositivo."; +"household_leave_confirm_confirm" = "Salir"; +"household_relaunch_needed" = "Cierra y vuelve a abrir MealMood para terminar el cambio."; +"household_error_not_signed_in" = "Identifícate antes de crear o unirte a un hogar."; +"household_error_no_household" = "Este dispositivo no pertenece a ningún hogar."; +"household_error_invalid_code" = "Ese código no existe."; +"household_error_expired_code" = "Ese código ha caducado. Pide uno nuevo."; diff --git a/MealMood/Resources/fr.lproj/Localizable.strings b/MealMood/Resources/fr.lproj/Localizable.strings index 4c1662d..4d6e4e7 100644 --- a/MealMood/Resources/fr.lproj/Localizable.strings +++ b/MealMood/Resources/fr.lproj/Localizable.strings @@ -478,3 +478,40 @@ "week_schedule_warning_confirm" = "Retirer quand même"; "toast_week_schedule_updated" = "Semaine mise à jour"; "toast_week_schedule_reset" = "Semaine revenue à vos réglages généraux"; + +/* Hogar compartido (2.2) */ +"household_title" = "Foyer partagé"; +"household_section" = "Foyer"; +"household_settings_footer" = "Planifiez la semaine à plusieurs, chacun depuis son compte. Fonctionne aussi depuis Android."; +"household_intro" = "Un foyer permet à plusieurs personnes de planifier la même semaine, avec les plats et la liste de courses en commun."; +"household_sign_in_footer" = "Il faut se connecter pour partager. Vos plats restent les vôtres : personne en dehors de votre foyer ne les voit."; +"household_create_section" = "Créer un foyer"; +"household_create" = "Créer le foyer"; +"household_name_placeholder" = "Nom du foyer (ex. Maison)"; +"household_create_footer" = "Vos plats, étiquettes et semaines actuels deviennent le point de départ du foyer."; +"household_create_premium_footer" = "Créer un foyer est réservé au Premium. La personne invitée n'a rien à payer."; +"household_join_section" = "Rejoindre un foyer"; +"household_join" = "Rejoindre avec le code"; +"household_code_placeholder" = "Code d'invitation"; +"household_join_footer" = "Demandez le code à la personne qui a créé le foyer. Il expire au bout de 7 jours."; +"household_join_choice_title" = "Et vos plats ?"; +"household_join_choice_message" = "Vous pouvez emmener vos plats et vos semaines dans ce foyer, ou commencer avec ceux du foyer uniquement."; +"household_join_choice_merge" = "Emmener mes plats"; +"household_join_choice_replace" = "Garder ceux du foyer"; +"household_members_section" = "Membres"; +"household_member_you" = "vous"; +"household_member_unnamed" = "Sans nom"; +"household_invite_section" = "Invitation"; +"household_invite_footer" = "Partagez ce code avec les personnes qui planifieront avec vous."; +"household_invite_share" = "Rejoins mon foyer MealMood avec le code %@ — https://mealmood.app"; +"household_invite_expires" = "Expire le %@"; +"household_invite_regenerate" = "Générer un nouveau code"; +"household_leave" = "Quitter le foyer"; +"household_leave_confirm_title" = "Quitter le foyer ?"; +"household_leave_confirm_message" = "Vous ne verrez plus et ne pourrez plus modifier les plats et les semaines du foyer sur cet appareil."; +"household_leave_confirm_confirm" = "Quitter"; +"household_relaunch_needed" = "Fermez et rouvrez MealMood pour terminer le changement."; +"household_error_not_signed_in" = "Connectez-vous avant de créer un foyer ou d'en rejoindre un."; +"household_error_no_household" = "Cet appareil n'appartient à aucun foyer."; +"household_error_invalid_code" = "Ce code n'existe pas."; +"household_error_expired_code" = "Ce code a expiré. Demandez-en un nouveau."; diff --git a/MealMood/Resources/it.lproj/Localizable.strings b/MealMood/Resources/it.lproj/Localizable.strings index e3da5e0..1267fb8 100644 --- a/MealMood/Resources/it.lproj/Localizable.strings +++ b/MealMood/Resources/it.lproj/Localizable.strings @@ -478,3 +478,40 @@ "week_schedule_warning_confirm" = "Rimuovi comunque"; "toast_week_schedule_updated" = "Settimana aggiornata"; "toast_week_schedule_reset" = "Settimana tornata alle impostazioni generali"; + +/* Hogar compartido (2.2) */ +"household_title" = "Casa condivisa"; +"household_section" = "Casa"; +"household_settings_footer" = "Pianificate la settimana insieme, ognuno dal proprio account. Funziona anche da Android."; +"household_intro" = "Una casa permette a più persone di pianificare la stessa settimana, condividendo piatti e lista della spesa."; +"household_sign_in_footer" = "Per condividere serve accedere. I tuoi piatti restano tuoi: fuori dalla tua casa non li vede nessuno."; +"household_create_section" = "Creare una casa"; +"household_create" = "Crea casa"; +"household_name_placeholder" = "Nome della casa (es. Casa)"; +"household_create_footer" = "I tuoi piatti, le etichette e le settimane attuali diventano il punto di partenza della casa."; +"household_create_premium_footer" = "Creare una casa è Premium. Chi ricevi l'invito non deve pagare."; +"household_join_section" = "Unirsi a una casa"; +"household_join" = "Unisciti con il codice"; +"household_code_placeholder" = "Codice d'invito"; +"household_join_footer" = "Chiedi il codice a chi ha creato la casa. Scade dopo 7 giorni."; +"household_join_choice_title" = "E i tuoi piatti?"; +"household_join_choice_message" = "Puoi portare in questa casa i piatti e le settimane che hai già, oppure iniziare solo con quelli della casa."; +"household_join_choice_merge" = "Porta i miei piatti"; +"household_join_choice_replace" = "Solo quelli della casa"; +"household_members_section" = "Membri"; +"household_member_you" = "tu"; +"household_member_unnamed" = "Senza nome"; +"household_invite_section" = "Invito"; +"household_invite_footer" = "Condividi questo codice con chi vuoi che pianifichi con te."; +"household_invite_share" = "Entra nella mia casa MealMood con il codice %@ — https://mealmood.app"; +"household_invite_expires" = "Scade il %@"; +"household_invite_regenerate" = "Genera un nuovo codice"; +"household_leave" = "Esci dalla casa"; +"household_leave_confirm_title" = "Uscire dalla casa?"; +"household_leave_confirm_message" = "Su questo dispositivo non vedrai né modificherai più i piatti e le settimane della casa."; +"household_leave_confirm_confirm" = "Esci"; +"household_relaunch_needed" = "Chiudi e riapri MealMood per completare il cambio."; +"household_error_not_signed_in" = "Accedi prima di creare una casa o di unirti a una."; +"household_error_no_household" = "Questo dispositivo non appartiene a nessuna casa."; +"household_error_invalid_code" = "Quel codice non esiste."; +"household_error_expired_code" = "Quel codice è scaduto. Chiedine uno nuovo."; diff --git a/MealMood/Resources/pt-BR.lproj/Localizable.strings b/MealMood/Resources/pt-BR.lproj/Localizable.strings index eb568a2..6307bbb 100644 --- a/MealMood/Resources/pt-BR.lproj/Localizable.strings +++ b/MealMood/Resources/pt-BR.lproj/Localizable.strings @@ -478,3 +478,40 @@ "week_schedule_warning_confirm" = "Remover mesmo assim"; "toast_week_schedule_updated" = "Semana atualizada"; "toast_week_schedule_reset" = "Semana de volta às configurações gerais"; + +/* Hogar compartido (2.2) */ +"household_title" = "Casa compartilhada"; +"household_section" = "Casa"; +"household_settings_footer" = "Planejem a semana juntos, cada um pela sua conta. Funciona também no Android."; +"household_intro" = "Uma casa permite que várias pessoas planejem a mesma semana, com os pratos e a lista de compras em comum."; +"household_sign_in_footer" = "Para compartilhar é preciso entrar. Seus pratos continuam sendo seus: ninguém fora da sua casa os vê."; +"household_create_section" = "Criar uma casa"; +"household_create" = "Criar casa"; +"household_name_placeholder" = "Nome da casa (ex.: Casa)"; +"household_create_footer" = "Seus pratos, etiquetas e semanas atuais viram o ponto de partida da casa."; +"household_create_premium_footer" = "Criar uma casa é Premium. Quem recebe o convite não precisa pagar."; +"household_join_section" = "Entrar em uma casa"; +"household_join" = "Entrar com o código"; +"household_code_placeholder" = "Código de convite"; +"household_join_footer" = "Peça o código a quem criou a casa. Ele expira em 7 dias."; +"household_join_choice_title" = "E os seus pratos?"; +"household_join_choice_message" = "Você pode levar para esta casa os pratos e semanas que já tem, ou começar só com os da casa."; +"household_join_choice_merge" = "Levar meus pratos"; +"household_join_choice_replace" = "Usar só os da casa"; +"household_members_section" = "Integrantes"; +"household_member_you" = "você"; +"household_member_unnamed" = "Sem nome"; +"household_invite_section" = "Convite"; +"household_invite_footer" = "Compartilhe este código com quem vai planejar com você."; +"household_invite_share" = "Entre na minha casa do MealMood com o código %@ — https://mealmood.app"; +"household_invite_expires" = "Expira em %@"; +"household_invite_regenerate" = "Gerar um código novo"; +"household_leave" = "Sair da casa"; +"household_leave_confirm_title" = "Sair da casa?"; +"household_leave_confirm_message" = "Você deixará de ver e editar os pratos e as semanas da casa neste dispositivo."; +"household_leave_confirm_confirm" = "Sair"; +"household_relaunch_needed" = "Feche e abra o MealMood de novo para concluir a mudança."; +"household_error_not_signed_in" = "Entre na sua conta antes de criar ou entrar em uma casa."; +"household_error_no_household" = "Este dispositivo não pertence a nenhuma casa."; +"household_error_invalid_code" = "Esse código não existe."; +"household_error_expired_code" = "Esse código expirou. Peça um novo."; diff --git a/MealMood/Services/Household/AuthService.swift b/MealMood/Services/Household/AuthService.swift new file mode 100644 index 0000000..9f01aff --- /dev/null +++ b/MealMood/Services/Household/AuthService.swift @@ -0,0 +1,141 @@ +import Foundation +import AuthenticationServices +import CryptoKit +import FirebaseAuth + +/// Firebase Auth wrapper for the household feature. Sign in with Apple only for +/// now — it is the one provider every iOS user already has, and Apple requires +/// offering it anyway once other social logins appear (Google arrives with the +/// Android PWA). +@MainActor +final class AuthService: ObservableObject { + static let shared = AuthService() + + @Published private(set) var uid: String? + @Published private(set) var displayName: String? + + /// Raw nonce of the sign-in in flight. Apple returns its SHA-256 inside the + /// identity token, and Firebase checks both match — that is what stops a + /// stolen token from being replayed. + private var currentNonce: String? + + private var stateListener: AuthStateDidChangeListenerHandle? + + private init() { + let user = Auth.auth().currentUser + uid = user?.uid + displayName = user?.displayName + stateListener = Auth.auth().addStateDidChangeListener { [weak self] _, user in + Task { @MainActor in + self?.uid = user?.uid + self?.displayName = user?.displayName + } + } + } + + var isSignedIn: Bool { uid != nil } + + // MARK: - Sign in with Apple + + /// Prepares the `ASAuthorizationAppleIDRequest` handed to + /// `SignInWithAppleButton`. + func prepare(request: ASAuthorizationAppleIDRequest) { + let nonce = Self.randomNonce() + currentNonce = nonce + request.requestedScopes = [.fullName, .email] + request.nonce = Self.sha256(nonce) + } + + /// Exchanges Apple's credential for a Firebase session. + func completeSignInWithApple(_ result: Result) 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." + } + } + } +} diff --git a/MealMood/Services/Household/HouseholdDocuments.swift b/MealMood/Services/Household/HouseholdDocuments.swift new file mode 100644 index 0000000..2a71fdd --- /dev/null +++ b/MealMood/Services/Household/HouseholdDocuments.swift @@ -0,0 +1,197 @@ +import Foundation +import CryptoKit + +/// Translation between the SwiftData models and the Firestore documents of a +/// household, plus the document ids and the content hashing the sync uses to +/// tell "changed locally" from "unchanged". +/// +/// `calendarEventId` and `photoData` are deliberately absent: the first is an +/// EventKit id that only means something on the device that created it, and the +/// second needs Firebase Storage (fase 2 in docs/household-sync.md). +enum HouseholdDocuments { + + // MARK: - Document ids + + /// Week documents are keyed by their Monday ("2026-09-14") instead of a + /// UUID so two members opening the same week write the same document + /// instead of creating two. + static func weekKey(for weekStart: Date) -> String { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: weekStart) + } + + /// Same idea for slots: "5-dinner" is derived from the content, so the + /// Saturday dinner of a week is one document no matter who creates it. + static func slotId(dayOfWeek: Int, mealType: String) -> String { + "\(dayOfWeek)-\(mealType)" + } + + // MARK: - Dish + + static func fields(for dish: Dish) -> [String: Any] { + [ + "name": dish.name, + "descriptionText": dish.descriptionText ?? NSNull(), + "tagIds": dish.tagIds.map(\.uuidString), + "ingredients": dish.ingredients, + "isPriority": dish.isPriority, + "fixedDayOfWeek": dish.fixedDayOfWeek ?? NSNull(), + "fixedMealType": dish.fixedMealType ?? NSNull(), + "createdAt": dish.createdAt.timeIntervalSince1970 + ] + } + + static func apply(_ data: [String: Any], to dish: Dish) { + dish.name = data["name"] as? String ?? dish.name + dish.descriptionText = data["descriptionText"] as? String + dish.tagIds = (data["tagIds"] as? [String] ?? []).compactMap(UUID.init(uuidString:)) + dish.ingredients = data["ingredients"] as? [String] ?? [] + dish.isPriority = data["isPriority"] as? Bool ?? false + dish.fixedDayOfWeek = data["fixedDayOfWeek"] as? Int + dish.fixedMealType = data["fixedMealType"] as? String + if let created = data["createdAt"] as? Double { + dish.createdAt = Date(timeIntervalSince1970: created) + } + } + + // MARK: - Tag + + static func fields(for tag: Tag) -> [String: Any] { + [ + "name": tag.name, + "nameEN": tag.nameEN, + "color": tag.color, + "maxPerWeek": tag.maxPerWeek ?? NSNull(), + "noConsecutive": tag.noConsecutive, + "noDuplicateInDay": tag.noDuplicateInDay, + "mealTypeRestriction": tag.mealTypeRestriction ?? NSNull(), + "dayRestriction": tag.dayRestriction ?? NSNull(), + "isDefault": tag.isDefault, + "sortOrder": tag.sortOrder + ] + } + + static func apply(_ data: [String: Any], to tag: Tag) { + tag.name = data["name"] as? String ?? tag.name + tag.nameEN = data["nameEN"] as? String ?? tag.nameEN + tag.color = data["color"] as? String ?? tag.color + tag.maxPerWeek = data["maxPerWeek"] as? Int + tag.noConsecutive = data["noConsecutive"] as? Bool ?? false + tag.noDuplicateInDay = data["noDuplicateInDay"] as? Bool ?? false + tag.mealTypeRestriction = data["mealTypeRestriction"] as? String + tag.dayRestriction = data["dayRestriction"] as? String + tag.isDefault = data["isDefault"] as? Bool ?? false + tag.sortOrder = data["sortOrder"] as? Int ?? 0 + } + + // MARK: - WeekPlan + + static func fields(for plan: WeekPlan) -> [String: Any] { + [ + "weekStartDate": plan.weekStartDate.timeIntervalSince1970, + "userRating": plan.userRating, + "includeWeekendsOverride": plan.includeWeekendsOverride ?? NSNull(), + "mealTypesOverrideRaw": plan.mealTypesOverrideRaw ?? NSNull() + ] + } + + static func apply(_ data: [String: Any], to plan: WeekPlan) { + plan.userRating = data["userRating"] as? Int ?? 0 + plan.includeWeekendsOverride = data["includeWeekendsOverride"] as? Bool + plan.mealTypesOverrideRaw = data["mealTypesOverrideRaw"] as? String + } + + // MARK: - MealSlot + + static func fields(for slot: MealSlot) -> [String: Any] { + [ + "dayOfWeek": slot.dayOfWeek, + "mealType": slot.mealType, + "dishId": slot.dishId?.uuidString ?? NSNull(), + "secondaryDishId": slot.secondaryDishId?.uuidString ?? NSNull(), + "isEatingOut": slot.isEatingOut, + "isSkipped": slot.isSkipped, + "isRuleOverridden": slot.isRuleOverridden, + "isRuleIgnored": slot.isRuleIgnored + ] + } + + static func apply(_ data: [String: Any], to slot: MealSlot) { + slot.dishId = (data["dishId"] as? String).flatMap(UUID.init(uuidString:)) + slot.secondaryDishId = (data["secondaryDishId"] as? String).flatMap(UUID.init(uuidString:)) + slot.isEatingOut = data["isEatingOut"] as? Bool ?? false + slot.isSkipped = data["isSkipped"] as? Bool ?? false + slot.isRuleOverridden = data["isRuleOverridden"] as? Bool ?? false + slot.isRuleIgnored = data["isRuleIgnored"] as? Bool ?? false + } + + // MARK: - ShoppingItem + + static func fields(for item: ShoppingItem) -> [String: Any] { + [ + "weekStartDate": item.weekStartDate.timeIntervalSince1970, + "title": item.title, + "dishId": item.dishId?.uuidString ?? NSNull(), + "isChecked": item.isChecked, + "isDismissed": item.isDismissed, + "sortOrder": item.sortOrder, + "createdAt": item.createdAt.timeIntervalSince1970 + ] + } + + static func apply(_ data: [String: Any], to item: ShoppingItem) { + item.title = data["title"] as? String ?? item.title + item.dishId = (data["dishId"] as? String).flatMap(UUID.init(uuidString:)) + item.isChecked = data["isChecked"] as? Bool ?? false + item.isDismissed = data["isDismissed"] as? Bool ?? false + item.sortOrder = data["sortOrder"] as? Int ?? 0 + if let created = data["createdAt"] as? Double { + item.createdAt = Date(timeIntervalSince1970: created) + } + } + + // MARK: - Content hashing + + /// Stable fingerprint of a document's payload. The sync keeps the last + /// synced fingerprint per id (the "shadow") and pushes only what differs, + /// which is what lets the app skip `updatedAt` bookkeeping in every view + /// that mutates a model. + static func fingerprint(_ fields: [String: Any]) -> String { + var parts: [String] = [] + for key in fields.keys.sorted() { + parts.append("\(key)=\(describe(fields[key]))") + } + let joined = parts.joined(separator: "&") + return SHA256.hash(data: Data(joined.utf8)) + .prefix(8) + .map { String(format: "%02x", $0) } + .joined() + } + + /// `Bool` and `Int` both bridge to `NSNumber`, so a plain `as? Bool` cast + /// would make `true` and `1` hash the same. CFBoolean is what tells them + /// apart. + private static func describe(_ value: Any?) -> String { + switch value { + case nil, is NSNull: + return "~" + case let string as String: + return string + case let array as [String]: + return "[" + array.joined(separator: ",") + "]" + case let number as NSNumber: + if CFGetTypeID(number) == CFBooleanGetTypeID() { + return number.boolValue ? "true" : "false" + } + if CFNumberIsFloatType(number as CFNumber) { + return String(format: "%.3f", number.doubleValue) + } + return number.stringValue + default: + return String(describing: value) + } + } +} diff --git a/MealMood/Services/Household/HouseholdRuntime.swift b/MealMood/Services/Household/HouseholdRuntime.swift new file mode 100644 index 0000000..02bfb78 --- /dev/null +++ b/MealMood/Services/Household/HouseholdRuntime.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Where the app's data lives this launch. +/// +/// A device inside a household syncs through Firestore and must NOT also run +/// CloudKit: two mirrors writing the same objects fight each other (the same +/// reason the legacy iCloud KV sync is disabled whenever CloudKit is active). +/// The store configuration is fixed when the `ModelContainer` is built at +/// launch, so joining or leaving a household only takes effect after a relaunch +/// — `needsRelaunch` tells the UI when to say so. +enum HouseholdRuntime { + private static let householdIdKey = "household_id" + private static let householdNameKey = "household_name" + + /// Household this device belongs to, or nil when planning solo. + static var householdId: String? { + get { UserDefaults.standard.string(forKey: householdIdKey) } + set { UserDefaults.standard.set(newValue, forKey: householdIdKey) } + } + + static var householdName: String? { + get { UserDefaults.standard.string(forKey: householdNameKey) } + set { UserDefaults.standard.set(newValue, forKey: householdNameKey) } + } + + /// Whether the store built at launch is the household (local + Firestore) + /// one. Written once during app start, read-only afterwards. + nonisolated(unsafe) static var isHouseholdStore = false + + /// True when the membership changed since the store was built, so the app + /// is running on the wrong store until it relaunches. + static var needsRelaunch: Bool { + (householdId != nil) != isHouseholdStore + } + + static func clear() { + householdId = nil + householdName = nil + } +} diff --git a/MealMood/Services/Household/HouseholdService.swift b/MealMood/Services/Household/HouseholdService.swift new file mode 100644 index 0000000..4e54aec --- /dev/null +++ b/MealMood/Services/Household/HouseholdService.swift @@ -0,0 +1,281 @@ +import Foundation +import FirebaseFirestore + +struct HouseholdSummary: Identifiable, Equatable { + let id: String + var name: String + var ownerId: String + var memberIds: [String] + var inviteCode: String? + var inviteExpiresAt: Date? +} + +struct HouseholdMember: Identifiable, Equatable { + let id: String // uid + var displayName: String + var role: String // "owner" | "member" + var joinedAt: Date +} + +/// Creating a household, inviting people into it and leaving it. The content +/// sync itself lives in `HouseholdSyncService`. +@MainActor +final class HouseholdService: ObservableObject { + static let shared = HouseholdService() + + private let db = Firestore.firestore() + + @Published private(set) var household: HouseholdSummary? + @Published private(set) var members: [HouseholdMember] = [] + + private var householdListener: ListenerRegistration? + private var membersListener: ListenerRegistration? + + private init() {} + + // MARK: - Lifecycle + + /// Starts watching the household this device belongs to, if any. + func start() { + guard let householdId = HouseholdRuntime.householdId else { return } + observe(householdId: householdId) + } + + func stop() { + householdListener?.remove() + membersListener?.remove() + householdListener = nil + membersListener = nil + household = nil + members = [] + } + + private func observe(householdId: String) { + householdListener?.remove() + membersListener?.remove() + + householdListener = db.collection("households").document(householdId) + .addSnapshotListener { [weak self] snapshot, _ in + guard let data = snapshot?.data() else { return } + Task { @MainActor in + self?.household = Self.summary(id: householdId, data: data) + HouseholdRuntime.householdName = data["name"] as? String + } + } + + membersListener = db.collection("households").document(householdId).collection("members") + .addSnapshotListener { [weak self] snapshot, _ in + let members = (snapshot?.documents ?? []).map { document -> HouseholdMember in + let data = document.data() + return HouseholdMember( + id: document.documentID, + displayName: data["displayName"] as? String ?? "", + role: data["role"] as? String ?? "member", + joinedAt: (data["joinedAt"] as? Timestamp)?.dateValue() ?? Date() + ) + } + Task { @MainActor in + self?.members = members.sorted { $0.joinedAt < $1.joinedAt } + } + } + } + + private static func summary(id: String, data: [String: Any]) -> HouseholdSummary { + HouseholdSummary( + id: id, + name: data["name"] as? String ?? "", + ownerId: data["ownerId"] as? String ?? "", + memberIds: data["memberIds"] as? [String] ?? [], + inviteCode: data["inviteCode"] as? String, + inviteExpiresAt: (data["inviteExpiresAt"] as? Timestamp)?.dateValue() + ) + } + + // MARK: - Create / join / leave + + func createHousehold(name: String, displayName: String) async throws -> HouseholdSummary { + guard let uid = AuthService.shared.uid else { throw HouseholdError.notSignedIn } + + let householdId = UUID().uuidString + let code = Self.makeInviteCode() + let expiresAt = Date().addingTimeInterval(Self.inviteLifetime) + let householdRef = db.collection("households").document(householdId) + + let batch = db.batch() + batch.setData([ + "name": name, + "ownerId": uid, + "memberIds": [uid], + "createdBy": uid, + "createdAt": FieldValue.serverTimestamp(), + "inviteCode": code, + "inviteExpiresAt": Timestamp(date: expiresAt) + ], forDocument: householdRef) + + batch.setData([ + "displayName": displayName, + "role": "owner", + "joinedAt": FieldValue.serverTimestamp() + ], forDocument: householdRef.collection("members").document(uid)) + + batch.setData([ + "householdId": householdId, + "createdBy": uid, + "expiresAt": Timestamp(date: expiresAt) + ], forDocument: db.collection("invites").document(code)) + + batch.setData([ + "householdId": householdId, + "displayName": displayName, + "updatedAt": FieldValue.serverTimestamp() + ], forDocument: db.collection("users").document(uid), merge: true) + + try await batch.commit() + + HouseholdRuntime.householdId = householdId + HouseholdRuntime.householdName = name + observe(householdId: householdId) + AnalyticsService.logEvent("household_created") + + return HouseholdSummary( + id: householdId, name: name, ownerId: uid, memberIds: [uid], + inviteCode: code, inviteExpiresAt: expiresAt + ) + } + + func join(code rawCode: String, displayName: String) async throws -> HouseholdSummary { + guard let uid = AuthService.shared.uid else { throw HouseholdError.notSignedIn } + let code = Self.normalize(rawCode) + + let inviteSnapshot = try await db.collection("invites").document(code).getDocument() + guard let invite = inviteSnapshot.data(), + let householdId = invite["householdId"] as? String else { + throw HouseholdError.invalidCode + } + if let expiresAt = (invite["expiresAt"] as? Timestamp)?.dateValue(), expiresAt < Date() { + throw HouseholdError.expiredCode + } + + let householdRef = db.collection("households").document(householdId) + // `lastJoinCode` is what the security rules check to allow a stranger to + // add themselves to `memberIds` — see firestore.rules. + try await householdRef.updateData([ + "memberIds": FieldValue.arrayUnion([uid]), + "lastJoinCode": code + ]) + try await householdRef.collection("members").document(uid).setData([ + "displayName": displayName, + "role": "member", + "joinedAt": FieldValue.serverTimestamp() + ]) + try await db.collection("users").document(uid).setData([ + "householdId": householdId, + "displayName": displayName, + "updatedAt": FieldValue.serverTimestamp() + ], merge: true) + + let snapshot = try await householdRef.getDocument() + let summary = Self.summary(id: householdId, data: snapshot.data() ?? [:]) + + HouseholdRuntime.householdId = householdId + HouseholdRuntime.householdName = summary.name + observe(householdId: householdId) + AnalyticsService.logEvent("household_joined") + + return summary + } + + func leaveHousehold() async throws { + guard let uid = AuthService.shared.uid else { throw HouseholdError.notSignedIn } + guard let householdId = HouseholdRuntime.householdId else { return } + + let householdRef = db.collection("households").document(householdId) + let snapshot = try await householdRef.getDocument() + let summary = Self.summary(id: householdId, data: snapshot.data() ?? [:]) + let remaining = summary.memberIds.filter { $0 != uid } + + if remaining.isEmpty { + // Last one out: the household and its content go with them. + try await householdRef.delete() + } else { + var updates: [String: Any] = ["memberIds": FieldValue.arrayRemove([uid])] + // Hand ownership over instead of leaving an orphan household. + if summary.ownerId == uid, let heir = remaining.first { + updates["ownerId"] = heir + } + try await householdRef.updateData(updates) + try await householdRef.collection("members").document(uid).delete() + } + + try await db.collection("users").document(uid).setData([ + "householdId": FieldValue.delete() + ], merge: true) + + stop() + HouseholdRuntime.clear() + AnalyticsService.logEvent("household_left") + } + + /// Issues a fresh invite code, invalidating the previous one. + @discardableResult + func regenerateInviteCode() async throws -> String { + guard let uid = AuthService.shared.uid else { throw HouseholdError.notSignedIn } + guard let householdId = HouseholdRuntime.householdId else { throw HouseholdError.noHousehold } + + let code = Self.makeInviteCode() + let expiresAt = Date().addingTimeInterval(Self.inviteLifetime) + let previousCode = household?.inviteCode + + try await db.collection("invites").document(code).setData([ + "householdId": householdId, + "createdBy": uid, + "expiresAt": Timestamp(date: expiresAt) + ]) + try await db.collection("households").document(householdId).updateData([ + "inviteCode": code, + "inviteExpiresAt": Timestamp(date: expiresAt) + ]) + if let previousCode, previousCode != code { + try? await db.collection("invites").document(previousCode).delete() + } + return code + } + + // MARK: - Invite codes + + nonisolated static let inviteLifetime: TimeInterval = 7 * 24 * 60 * 60 + + /// Six characters, no vowels (no accidental words) and no 0/O/1/I so they + /// survive being read out loud or typed from a screenshot. + nonisolated static let inviteAlphabet = Array("23456789BCDFGHJKLMNPQRSTVWXYZ") + + nonisolated static func makeInviteCode() -> String { + String((0..<6).map { _ in inviteAlphabet.randomElement() ?? "X" }) + } + + /// Uppercases and drops anything outside the alphabet, so "bcdf-gh" and + /// "BCDF GH" both reach Firestore as "BCDFGH". + nonisolated static func normalize(_ code: String) -> String { + String(code.uppercased().filter { inviteAlphabet.contains($0) }) + } + + nonisolated static func isPlausibleCode(_ code: String) -> Bool { + normalize(code).count == 6 + } + + enum HouseholdError: LocalizedError { + case notSignedIn + case noHousehold + case invalidCode + case expiredCode + + var errorDescription: String? { + switch self { + case .notSignedIn: return String(localized: "household_error_not_signed_in") + case .noHousehold: return String(localized: "household_error_no_household") + case .invalidCode: return String(localized: "household_error_invalid_code") + case .expiredCode: return String(localized: "household_error_expired_code") + } + } + } +} diff --git a/MealMood/Services/Household/HouseholdSyncService.swift b/MealMood/Services/Household/HouseholdSyncService.swift new file mode 100644 index 0000000..384d9e2 --- /dev/null +++ b/MealMood/Services/Household/HouseholdSyncService.swift @@ -0,0 +1,478 @@ +import Foundation +import SwiftData +import FirebaseFirestore + +/// Mirrors the household's content between the local SwiftData store and +/// Firestore. The UI keeps reading SwiftData through `@Query`; this service is +/// the only thing that talks to the network. +/// +/// How changes are detected without touching every mutation site in the app: +/// each synced document carries a fingerprint of its content, and the last +/// fingerprint pushed or received per document (the "shadow") is persisted. +/// Local content whose fingerprint no longer matches the shadow is a local +/// edit; an id present in the shadow but gone locally is a local delete, which +/// becomes a `deletedAt` tombstone (a hard delete would come back from any +/// member who was offline when it happened). +/// +/// Conflicts resolve last-write-wins per document. Documents are small and +/// granular — one per meal slot — so two people filling different days of the +/// same week never collide. +@MainActor +final class HouseholdSyncService: ObservableObject { + static let shared = HouseholdSyncService() + + @Published private(set) var isSyncing = false + @Published private(set) var lastSyncedAt: Date? + @Published private(set) var lastError: String? + + private let db = Firestore.firestore() + private var context: ModelContext? + private var householdId: String? + + private var contentListeners: [ListenerRegistration] = [] + private var slotListeners: [String: ListenerRegistration] = [:] + private var focusedWeeks: [String] = [] + private static let maxFocusedWeeks = 6 + + private var shadow = SyncShadow() + private var pushTask: Task? + 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())) ?? [] + 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())) ?? [] + 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())) ?? [] + 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())) ?? [] + 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())) ?? [] + 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())) ?? [] { + documents["dishes/\(dish.id.uuidString)"] = HouseholdDocuments.fields(for: dish) + } + for tag in (try? context.fetch(FetchDescriptor())) ?? [] { + documents["tags/\(tag.id.uuidString)"] = HouseholdDocuments.fields(for: tag) + } + for item in (try? context.fetch(FetchDescriptor())) ?? [] { + documents["shoppingItems/\(item.id.uuidString)"] = HouseholdDocuments.fields(for: item) + } + for plan in (try? context.fetch(FetchDescriptor())) ?? [] { + 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())) ?? [] { + for slot in plan.slotList { context.delete(slot) } + context.delete(plan) + } + for dish in (try? context.fetch(FetchDescriptor())) ?? [] { context.delete(dish) } + for tag in (try? context.fetch(FetchDescriptor())) ?? [] { context.delete(tag) } + for item in (try? context.fetch(FetchDescriptor())) ?? [] { 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.. Void) { + isWorking = true + errorMessage = nil + Task { + do { + try await operation() + } catch { + errorMessage = error.localizedDescription + CrashlyticsService.record(error, context: "household_action") + } + isWorking = false + } + } +} diff --git a/MealMood/Views/Settings/SettingsView.swift b/MealMood/Views/Settings/SettingsView.swift index c2aafa7..75995b1 100644 --- a/MealMood/Views/Settings/SettingsView.swift +++ b/MealMood/Views/Settings/SettingsView.swift @@ -227,6 +227,26 @@ struct SettingsView: View { .listRowBackground(Color.mealMoodSurface) .task { await refreshNotificationStatus() } + // Household section (2.2): shared planning across accounts. + Section { + NavigationLink(destination: HouseholdView()) { + HStack { + Text("household_title") + Spacer() + if let name = HouseholdRuntime.householdName, HouseholdRuntime.householdId != nil { + Text(name) + .font(.mealMoodCaption) + .foregroundColor(.mealMoodTextSecondary) + } + } + } + } header: { + Label("household_section", systemImage: "person.2") + } footer: { + Text("household_settings_footer") + } + .listRowBackground(Color.mealMoodSurface) + // Tags section Section { NavigationLink(destination: TagListView()) { diff --git a/MealMoodTests/HouseholdDocumentsTests.swift b/MealMoodTests/HouseholdDocumentsTests.swift new file mode 100644 index 0000000..9f50a47 --- /dev/null +++ b/MealMoodTests/HouseholdDocumentsTests.swift @@ -0,0 +1,241 @@ +import XCTest +import SwiftData +@testable import MealMood + +/// The pure half of the household sync: document ids, fingerprints and the +/// model ↔ Firestore mapping. Anything touching the network stays out. +final class HouseholdDocumentsTests: XCTestCase { + + /// Held for the duration of the test: releasing the container tears the + /// store off the coordinator and the next SwiftData call traps. + private var container: ModelContainer! + private var context: ModelContext! + + override func setUpWithError() throws { + try super.setUpWithError() + container = try ModelContainer( + for: AppSettings.self, Dish.self, Tag.self, WeekPlan.self, MealSlot.self, ShoppingItem.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + context = ModelContext(container) + } + + override func tearDown() { + context = nil + container = nil + super.tearDown() + } + + // MARK: - Document ids + + func testWeekKeyIsTheMondayDate() { + let monday = Calendar(identifier: .gregorian) + .date(from: DateComponents(year: 2026, month: 9, day: 14))! + + XCTAssertEqual(HouseholdDocuments.weekKey(for: monday), "2026-09-14") + } + + func testWeekKeyIsStableAcrossLocales() { + let monday = Calendar(identifier: .gregorian) + .date(from: DateComponents(year: 2026, month: 1, day: 5))! + + // Two members with different phone languages must write the same + // document, not one each. + XCTAssertEqual(HouseholdDocuments.weekKey(for: monday), "2026-01-05") + } + + func testSlotIdIsDerivedFromContent() { + XCTAssertEqual(HouseholdDocuments.slotId(dayOfWeek: 5, mealType: "dinner"), "5-dinner") + XCTAssertEqual(HouseholdDocuments.slotId(dayOfWeek: 0, mealType: "breakfast"), "0-breakfast") + } + + // MARK: - Fingerprints + + func testFingerprintIgnoresKeyOrder() { + let a: [String: Any] = ["name": "Tortilla", "isPriority": true, "sortOrder": 3] + let b: [String: Any] = ["sortOrder": 3, "name": "Tortilla", "isPriority": true] + + XCTAssertEqual(HouseholdDocuments.fingerprint(a), HouseholdDocuments.fingerprint(b)) + } + + func testFingerprintChangesWithContent() { + let dish = Dish(name: "Tortilla") + context.insert(dish) + let before = HouseholdDocuments.fingerprint(HouseholdDocuments.fields(for: dish)) + + dish.name = "Tortilla de patatas" + let after = HouseholdDocuments.fingerprint(HouseholdDocuments.fields(for: dish)) + + XCTAssertNotEqual(before, after, "an edited dish must be detected as a local change") + } + + func testFingerprintDistinguishesBoolFromInt() { + let asBool: [String: Any] = ["value": true] + let asInt: [String: Any] = ["value": 1] + + XCTAssertNotEqual(HouseholdDocuments.fingerprint(asBool), HouseholdDocuments.fingerprint(asInt)) + } + + // MARK: - Round trips + + func testDishSurvivesARoundTrip() { + let tagId = UUID() + let original = Dish( + name: "Salmón al horno", + descriptionText: "con limón", + tagIds: [tagId], + isPriority: true, + ingredients: ["salmón", "limón"], + fixedDayOfWeek: 4, + fixedMealType: "dinner" + ) + context.insert(original) + + let copy = Dish(id: original.id, name: "") + context.insert(copy) + HouseholdDocuments.apply(HouseholdDocuments.fields(for: original), to: copy) + + XCTAssertEqual(copy.name, "Salmón al horno") + XCTAssertEqual(copy.descriptionText, "con limón") + XCTAssertEqual(copy.tagIds, [tagId]) + XCTAssertEqual(copy.ingredients, ["salmón", "limón"]) + XCTAssertTrue(copy.isPriority) + XCTAssertEqual(copy.fixedDayOfWeek, 4) + XCTAssertEqual(copy.fixedMealType, "dinner") + XCTAssertEqual( + HouseholdDocuments.fingerprint(HouseholdDocuments.fields(for: original)), + HouseholdDocuments.fingerprint(HouseholdDocuments.fields(for: copy)), + "a round trip must not look like a change to push" + ) + } + + func testTagRulesSurviveARoundTrip() { + let original = Tag( + name: "Pescado", nameEN: "Fish", color: "#4FC3F7", + maxPerWeek: 2, noConsecutive: true, noDuplicateInDay: true, + mealTypeRestriction: "dinner", dayRestriction: "weekdays", + isDefault: false, sortOrder: 7 + ) + context.insert(original) + + let copy = Tag(id: original.id, name: "", color: "") + context.insert(copy) + HouseholdDocuments.apply(HouseholdDocuments.fields(for: original), to: copy) + + XCTAssertEqual(copy.nameEN, "Fish") + XCTAssertEqual(copy.maxPerWeek, 2) + XCTAssertTrue(copy.noConsecutive) + XCTAssertEqual(copy.mealTypeRestriction, "dinner") + XCTAssertEqual(copy.dayRestriction, "weekdays", "day rules must travel or they'd silently reset") + XCTAssertEqual(copy.sortOrder, 7) + } + + func testSlotSurvivesARoundTripWithoutTheCalendarEventId() { + let original = MealSlot(dayOfWeek: 2, mealType: "lunch") + original.dishId = UUID() + original.secondaryDishId = UUID() + original.isSkipped = true + original.calendarEventId = "local-eventkit-id" + context.insert(original) + + let copy = MealSlot(dayOfWeek: 2, mealType: "lunch") + context.insert(copy) + HouseholdDocuments.apply(HouseholdDocuments.fields(for: original), to: copy) + + XCTAssertEqual(copy.dishId, original.dishId) + XCTAssertEqual(copy.secondaryDishId, original.secondaryDishId) + XCTAssertTrue(copy.isSkipped) + // The EventKit id only means something on the device that created it. + XCTAssertNil(copy.calendarEventId) + XCTAssertNil(HouseholdDocuments.fields(for: original)["calendarEventId"]) + } + + func testWeekPlanCarriesItsScheduleOverride() { + let plan = WeekPlan(weekStartDate: Date().startOfWeek()) + plan.includeWeekendsOverride = true + plan.mealTypesOverrideRaw = "breakfast,dinner" + plan.userRating = 1 + context.insert(plan) + + let copy = WeekPlan(weekStartDate: plan.weekStartDate) + context.insert(copy) + HouseholdDocuments.apply(HouseholdDocuments.fields(for: plan), to: copy) + + XCTAssertEqual(copy.includeWeekendsOverride, true) + XCTAssertEqual(copy.mealTypesOverrideRaw, "breakfast,dinner") + XCTAssertEqual(copy.userRating, 1) + } + + func testShoppingItemSurvivesARoundTrip() { + let item = ShoppingItem(weekStartDate: Date().startOfWeek(), title: "Leche", isChecked: true, sortOrder: 4) + context.insert(item) + + let copy = ShoppingItem(id: item.id, weekStartDate: item.weekStartDate, title: "") + context.insert(copy) + HouseholdDocuments.apply(HouseholdDocuments.fields(for: item), to: copy) + + XCTAssertEqual(copy.title, "Leche") + XCTAssertTrue(copy.isChecked) + XCTAssertEqual(copy.sortOrder, 4) + } + + // MARK: - Invite codes + + func testInviteCodesUseAnUnambiguousAlphabet() { + for _ in 0..<200 { + let code = HouseholdService.makeInviteCode() + XCTAssertEqual(code.count, 6) + // No 0/O/1/I and no vowels: codes get read out loud and retyped. + XCTAssertFalse(code.contains(where: { "01IOAEU".contains($0) }), "ambiguous character in \(code)") + } + } + + func testCodeNormalizationAcceptsHowPeopleTypeIt() { + let code = HouseholdService.makeInviteCode() + let messy = code.lowercased().split(separator: "").joined() + let spaced = code.map(String.init).joined(separator: " ") + let dashed = code.prefix(3) + "-" + code.suffix(3) + + XCTAssertEqual(HouseholdService.normalize(messy), code) + XCTAssertEqual(HouseholdService.normalize(spaced), code) + XCTAssertEqual(HouseholdService.normalize(String(dashed)), code) + XCTAssertTrue(HouseholdService.isPlausibleCode(spaced)) + } + + func testShortOrEmptyCodesAreRejectedBeforeHittingTheNetwork() { + XCTAssertFalse(HouseholdService.isPlausibleCode("")) + XCTAssertFalse(HouseholdService.isPlausibleCode("BCD")) + XCTAssertFalse(HouseholdService.isPlausibleCode("AEIOU"), "vowels aren't part of the alphabet") + } + + // MARK: - Shadow + + func testShadowTracksWhatWasSynced() { + var shadow = SyncShadow() + shadow.set(path: "dishes/abc", fingerprint: "1234") + + XCTAssertEqual(shadow.fingerprint(for: "dishes/abc"), "1234") + XCTAssertNil(shadow.fingerprint(for: "dishes/other")) + + shadow.remove(path: "dishes/abc") + XCTAssertNil(shadow.fingerprint(for: "dishes/abc")) + + shadow.set(path: "tags/x", fingerprint: "ffff") + shadow.reset() + XCTAssertTrue(shadow.paths.isEmpty) + } + + func testShadowSurvivesARelaunch() { + let householdId = "test-\(UUID().uuidString)" + var shadow = SyncShadow() + shadow.set(path: "dishes/abc", fingerprint: "1234") + shadow.save(householdId: householdId) + + var restored = SyncShadow() + restored.load(householdId: householdId) + + XCTAssertEqual(restored.fingerprint(for: "dishes/abc"), "1234", + "without this the whole store would re-upload on every launch") + UserDefaults.standard.removeObject(forKey: "household_shadow_\(householdId)") + } +} diff --git a/MealMoodUITests/HouseholdUITests.swift b/MealMoodUITests/HouseholdUITests.swift new file mode 100644 index 0000000..0f86e3e --- /dev/null +++ b/MealMoodUITests/HouseholdUITests.swift @@ -0,0 +1,40 @@ +import XCTest + +/// The household screen has to be reachable from Settings and offer sign-in +/// before anything else — none of which a unit test can see. +final class HouseholdUITests: XCTestCase { + + func testHouseholdScreenIsReachableFromSettings() throws { + let app = XCUIApplication() + app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US", "UITEST_DISABLE_ANALYTICS"] + app.launch() + + if !app.otherElements["home_root"].waitForExistence(timeout: 5) { + for id in ["onboarding_cta_welcome", "onboarding_cta_meal_windows", "onboarding_cta_weekends", + "onboarding_cta_calendar", "onboarding_cta_first_dishes", + "onboarding_cta_week_ready_skip", "onboarding_cta_paywall_skip"] { + let button = app.buttons[id] + if button.waitForExistence(timeout: 10) { button.tap() } + } + XCTAssertTrue(app.otherElements["home_root"].waitForExistence(timeout: 15), "Home never appeared") + } + + app.buttons.matching(identifier: "gearshape").firstMatch.tap() + let settingsRow = app.buttons["Shared household"].firstMatch + XCTAssertTrue(settingsRow.waitForExistence(timeout: 10), "Household row missing from Settings") + settingsRow.tap() + + XCTAssertTrue(app.navigationBars["Shared household"].waitForExistence(timeout: 5), + "Household screen did not open") + // Signed out: sign-in comes first, creating or joining is not offered yet. + XCTAssertTrue(app.buttons["Sign in with Apple"].waitForExistence(timeout: 5), "Apple sign-in button missing") + XCTAssertFalse(app.buttons["Create household"].exists, "Create must wait until there is an account") + + let screenshot = app.screenshot() + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = "household-signed-out" + attachment.lifetime = .keepAlways + add(attachment) + try? screenshot.pngRepresentation.write(to: URL(fileURLWithPath: "/tmp/mealmood-household.png")) + } +} diff --git a/docs/household-sync.md b/docs/household-sync.md new file mode 100644 index 0000000..28482fe --- /dev/null +++ b/docs/household-sync.md @@ -0,0 +1,112 @@ +# Hogar compartido (2.2) — diseño + +Colaboración entre cuentas distintas y acceso desde Android. Issue #33. + +## Por qué no CloudKit + +El store es hoy `NSPersistentCloudKitContainer` (SwiftData con +`cloudKitDatabase: .automatic`), que sincroniza la base de datos **privada** de +un Apple ID entre los dispositivos de esa persona. Dos cosas lo descartan aquí: + +- No existe en Android. +- Compartir entre cuentas exige `CKShare`, que SwiftData no expone (ya se + intentó en la 2.0 y se aparcó a la 2.1). + +## Arquitectura + +``` + ┌──────────────────────────┐ + iOS / iPadOS │ SwiftData (store local) │ ← toda la UI sigue leyendo de aquí (@Query) + └────────────┬─────────────┘ + │ HouseholdSyncService (espejo bidireccional) + ┌────────────┴─────────────┐ + │ Firestore (el hogar) │ ← fuente de verdad cuando hay hogar + └────────────┬─────────────┘ + │ + Android / web │ PWA (fase 2), lee y escribe el mismo hogar +``` + +SwiftData **no** se sustituye: sigue siendo el store local y el modo offline. +Firestore es la fuente de verdad del contenido del hogar, y el servicio de sync +mantiene ambos lados alineados. + +### CloudKit y Firestore no conviven + +Dos sincronizaciones sobre los mismos objetos se pelean — ya pasó con el sync +por iCloud KV, y por eso existe `CloudSyncRuntime.isCloudKitActive`. Regla: + +- Sin hogar → como hoy: CloudKit activo, Firestore inactivo. +- Con hogar → contenedor local (`cloudKitDatabase: .none`) y Firestore manda. + +El contenedor se decide en el arranque, así que al entrar o salir de un hogar +la app pide relanzarse. Es un corte visible, pero la alternativa (dos orígenes +escribiendo los mismos objetos, con ecos entre ellos) es una fuente de pérdida +de datos. + +## Modelo en Firestore + +``` +users/{uid} + householdId, displayName, updatedAt + +invites/{code} ← resuelve código → hogar sin leer todos los hogares + householdId, createdBy, expiresAt + +households/{householdId} + name, createdAt, createdBy, memberIds: [uid], ownerId + members/{uid} displayName, role: owner|member, joinedAt + dishes/{uuid} name, descriptionText, tagIds[], ingredients[], isPriority, + fixedDayOfWeek, fixedMealType, updatedAt, updatedBy, deletedAt? + tags/{uuid} name, nameEN, color, reglas…, updatedAt, updatedBy, deletedAt? + weekPlans/{yyyy-MM-dd} weekStartDate, userRating, includeWeekendsOverride, + mealTypesOverrideRaw, updatedAt, updatedBy + slots/{day}-{meal} dishId, secondaryDishId, isEatingOut, isSkipped, + isRuleOverridden, isRuleIgnored, updatedAt, updatedBy + shoppingItems/{uuid} weekStartDate, title, dishId, isChecked, isDismissed, + sortOrder, updatedAt, updatedBy, deletedAt? +``` + +### IDs deterministas donde importa + +Semanas y slots usan id derivado del contenido (`2026-09-14`, `5-dinner`) en vez +de UUID. Si dos miembros abren la misma semana a la vez, ambos escriben el mismo +documento en lugar de crear dos. Es la misma clase de duplicado que costó el +crash de arranque de la 2.0, pero resuelta en el origen. + +Platos, etiquetas y líneas de la compra conservan su UUID: los crea una persona +concreta y no hay id natural. + +## Conflictos + +Last-write-wins por **documento**, con `updatedAt` de servidor +(`FieldValue.serverTimestamp()`) y `updatedBy` para poder depurar. La +granularidad importa: el documento es el slot, no la semana, así que dos +personas pueden rellenar martes y jueves a la vez sin pisarse. + +Los borrados son tombstones (`deletedAt`), no borrados duros: sin ellos, un +dispositivo offline que no vio el borrado recrearía el plato al volver. + +## Alcance de la fase 1 + +Dentro: + +- Cuentas (Sign in with Apple + Google) y hogar con invitación por código. +- Sync de platos, etiquetas, semanas, slots y lista de la compra. +- Desactivación de CloudKit al entrar en un hogar. + +Fuera, a propósito: + +- **Fotos de platos**: `photoData` va a `@Attribute(.externalStorage)` y un + documento de Firestore tope a 1 MB. Van a Firebase Storage en fase 2. +- **Ajustes personales** (`AppSettings`): no se comparten. Idioma, horas de + calendario o estilo de export son de cada persona. Lo que sí es del hogar + (días y comidas planificadas) viaja en el propio `weekPlan`. +- **PWA de Android**: fase 2, sobre este mismo modelo. + +## Premium + +Crear un hogar requiere premium. Los invitados entran sin premium — es el gancho +del plan familiar, y quien invita ya paga. El resto de features premium siguen +bloqueadas para el invitado. + +`isPremium` sigue viniendo solo de StoreKit y nunca se sincroniza, igual que hoy. diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 0000000..6405953 --- /dev/null +++ b/firestore.rules @@ -0,0 +1,96 @@ +rules_version = '2'; + +// Hogar compartido de MealMood (issue #33). Ver docs/household-sync.md. +// +// Regla de oro: el contenido de un hogar solo lo ven y lo escriben sus +// miembros. Lo único legible desde fuera es un código de invitación concreto, +// y solo por id — nunca listando la colección. +service cloud.firestore { + match /databases/{database}/documents { + + function signedIn() { + return request.auth != null; + } + + function uid() { + return request.auth.uid; + } + + function isMember(householdId) { + return signedIn() + && uid() in get(/databases/$(database)/documents/households/$(householdId)).data.memberIds; + } + + // Perfil mínimo: a qué hogar pertenece cada persona y su nombre visible. + match /users/{userId} { + allow read, write: if signedIn() && uid() == userId; + } + + // Códigos de invitación. `get` abierto a cualquier persona autenticada — + // es la única forma de resolver un código antes de ser miembro. `list` + // queda prohibido para que nadie pueda barrer códigos ajenos. + match /invites/{code} { + allow get: if signedIn(); + allow list: if false; + allow create, delete: if signedIn() + && request.resource.data.createdBy == uid(); + allow update: if false; + } + + match /households/{householdId} { + allow get: if isMember(householdId); + allow list: if false; + + // Al crear, la persona queda como dueña y único miembro. + allow create: if signedIn() + && request.resource.data.ownerId == uid() + && request.resource.data.memberIds == [uid()]; + + // Dos actualizaciones legítimas: + // - un miembro cambia datos del hogar (nombre, código de invitación), + // - alguien de fuera se añade a sí mismo con un código válido. + allow update: if isMember(householdId) || joiningWithValidInvite(householdId); + + allow delete: if signedIn() + && resource.data.ownerId == uid(); + + function joiningWithValidInvite(householdId) { + let code = request.resource.data.lastJoinCode; + let invitePath = /databases/$(database)/documents/invites/$(code); + return signedIn() + && exists(invitePath) + && get(invitePath).data.householdId == householdId + && get(invitePath).data.expiresAt > request.time + // Solo puede añadirse a sí misma, y sin tocar a nadie más. + && request.resource.data.memberIds == resource.data.memberIds.concat([uid()]); + } + + match /members/{memberId} { + allow read: if isMember(householdId); + // Cada persona escribe su propia ficha; nadie edita la de otro. + allow write: if signedIn() && uid() == memberId; + } + + // Contenido del hogar: platos, etiquetas, semanas, slots y compra. + match /dishes/{dishId} { + allow read, write: if isMember(householdId); + } + + match /tags/{tagId} { + allow read, write: if isMember(householdId); + } + + match /weekPlans/{weekKey} { + allow read, write: if isMember(householdId); + + match /slots/{slotId} { + allow read, write: if isMember(householdId); + } + } + + match /shoppingItems/{itemId} { + allow read, write: if isMember(householdId); + } + } + } +}