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