premium: configurar días y comidas de una semana concreta

Hasta ahora includeWeekends y activeMealTypes vivían solo en AppSettings y
aplicaban a todo el calendario. WeekPlan puede ahora sobreescribirlos para su
semana (includeWeekendsOverride / mealTypesOverrideRaw) y WeekSchedule resuelve
qué manda en cada semana: su override si lo tiene, los ajustes globales si no.

Todo lo que pintaba días y comidas —planificador, exports, compartir texto,
widget, watch— pasa por settings.schedule(for:), así que una semana con fin de
semana añadido o con desayuno activo se ve igual en todas partes.

Crear o editar el override es premium; los ya guardados se respetan aunque la
suscripción caduque, para no borrar comidas ya planificadas. La hoja avisa y
pide confirmación cuando quitar un día o una comida se lleva por delante algo
ya planificado.

Refs #32

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246
This commit is contained in:
alexandrev-tibco
2026-09-12 12:05:34 +02:00
parent e58227a113
commit 66128e7a5c
21 changed files with 777 additions and 17 deletions
+16
View File
@@ -35,6 +35,7 @@
511DDB82FF4DD30018101418 /* DishDrawerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23957C735075B0DFB1DF51D7 /* DishDrawerView.swift */; }; 511DDB82FF4DD30018101418 /* DishDrawerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23957C735075B0DFB1DF51D7 /* DishDrawerView.swift */; };
519AA84E7D3B9ED5FC215E4B /* MealMoodWatchWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A43C4A6B38F731B9FE6AACB /* MealMoodWatchWidget.swift */; }; 519AA84E7D3B9ED5FC215E4B /* MealMoodWatchWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A43C4A6B38F731B9FE6AACB /* MealMoodWatchWidget.swift */; };
57F1EA6B5E3AF88A61C48354 /* ShoppingListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BA71D570D9882C3AFAFDC48 /* ShoppingListView.swift */; }; 57F1EA6B5E3AF88A61C48354 /* ShoppingListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BA71D570D9882C3AFAFDC48 /* ShoppingListView.swift */; };
57F913037D2D381DFA1FB7DE /* WeekScheduleSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A46F6218891B47163E5DDA /* WeekScheduleSheet.swift */; };
5828DA9ED4D8B892E9EFC1F4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CBB9DAFC0FC788CF491CF9C8 /* Assets.xcassets */; }; 5828DA9ED4D8B892E9EFC1F4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CBB9DAFC0FC788CF491CF9C8 /* Assets.xcassets */; };
5BCED5AFADACD4118AE59AF9 /* Font+MealMood.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE73D5B7AF8B5E2D63105977 /* Font+MealMood.swift */; }; 5BCED5AFADACD4118AE59AF9 /* Font+MealMood.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE73D5B7AF8B5E2D63105977 /* Font+MealMood.swift */; };
5E7322F3F3624211985F2D99 /* ICloudSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED090ADA1FB259AD2AFEDE2 /* ICloudSyncService.swift */; }; 5E7322F3F3624211985F2D99 /* ICloudSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED090ADA1FB259AD2AFEDE2 /* ICloudSyncService.swift */; };
@@ -56,6 +57,7 @@
861557A13918B99C421251E0 /* PremiumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324D77FEC4C8C371695F27CC /* PremiumView.swift */; }; 861557A13918B99C421251E0 /* PremiumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324D77FEC4C8C371695F27CC /* PremiumView.swift */; };
867A511F154A33E9DD7FE9BC /* CalendarService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A631C6D0CF7A0724ACF6A5 /* CalendarService.swift */; }; 867A511F154A33E9DD7FE9BC /* CalendarService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A631C6D0CF7A0724ACF6A5 /* CalendarService.swift */; };
87EB371FD3B6C241AC721C55 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 8E15828872FAEA71A7384099 /* Localizable.strings */; }; 87EB371FD3B6C241AC721C55 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 8E15828872FAEA71A7384099 /* Localizable.strings */; };
8B12FAE3CC07AFB284094C61 /* WeekSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27337A07289450A1D494AC02 /* WeekSchedule.swift */; };
8B4672C76B05BC1DFB5C979D /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5018BA4B3DC94CA7443CA9D1 /* TodayView.swift */; }; 8B4672C76B05BC1DFB5C979D /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5018BA4B3DC94CA7443CA9D1 /* TodayView.swift */; };
8CEAC8993D33BFBBEB5319A8 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EE10285FEE9BE0684CC66A3 /* NotificationService.swift */; }; 8CEAC8993D33BFBBEB5319A8 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EE10285FEE9BE0684CC66A3 /* NotificationService.swift */; };
8DB624B469178CFD8CD87672 /* OnboardingFlowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBD59AC26D5C6D138D67E35 /* OnboardingFlowUITests.swift */; }; 8DB624B469178CFD8CD87672 /* OnboardingFlowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBD59AC26D5C6D138D67E35 /* OnboardingFlowUITests.swift */; };
@@ -68,6 +70,7 @@
A1B2C3D4E5F60718293A4B01 /* IngredientParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B02 /* IngredientParser.swift */; }; A1B2C3D4E5F60718293A4B01 /* IngredientParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B02 /* IngredientParser.swift */; };
A1B2C3D4E5F60718293A4B03 /* SpeechDictationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B04 /* SpeechDictationService.swift */; }; A1B2C3D4E5F60718293A4B03 /* SpeechDictationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B04 /* SpeechDictationService.swift */; };
A4D50C0B4E91B3542C63017A /* MealMoodWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = B042BB2F81973D854E84BABD /* MealMoodWidget.swift */; }; A4D50C0B4E91B3542C63017A /* MealMoodWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = B042BB2F81973D854E84BABD /* MealMoodWidget.swift */; };
A569F5FED382E8EABC471416 /* WeekScheduleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91C2BCF9D7A1458E045DA053 /* WeekScheduleTests.swift */; };
A6F130EC376B7BC270AD908D /* TagPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E1C12C3A693D7C41E9F80B /* TagPill.swift */; }; A6F130EC376B7BC270AD908D /* TagPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E1C12C3A693D7C41E9F80B /* TagPill.swift */; };
A7CAD588CF78F404C8065CF1 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EDEE086FD20D16DE507B247 /* HomeViewModel.swift */; }; A7CAD588CF78F404C8065CF1 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EDEE086FD20D16DE507B247 /* HomeViewModel.swift */; };
A805D5DEA5B7EA1374FE3118 /* MealMoodWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB16B89093E7AE8B9B05202 /* MealMoodWatchApp.swift */; }; A805D5DEA5B7EA1374FE3118 /* MealMoodWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB16B89093E7AE8B9B05202 /* MealMoodWatchApp.swift */; };
@@ -89,6 +92,7 @@
C6DBE6B5B4BE002A234CBCCA /* WatchSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449577ECE261177D4A1C1572 /* WatchSyncService.swift */; }; C6DBE6B5B4BE002A234CBCCA /* WatchSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449577ECE261177D4A1C1572 /* WatchSyncService.swift */; };
C7B1F5A21B5E4A9E91B7F5A2 /* BottomPromoBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E2F6A3F47E5C9F92A8E6B3 /* BottomPromoBanner.swift */; }; C7B1F5A21B5E4A9E91B7F5A2 /* BottomPromoBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E2F6A3F47E5C9F92A8E6B3 /* BottomPromoBanner.swift */; };
C81C935386BE1E939DA55F8D /* MealWindowsStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA11C8A81D5C0CA8A3C13D /* MealWindowsStepView.swift */; }; C81C935386BE1E939DA55F8D /* MealWindowsStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA11C8A81D5C0CA8A3C13D /* MealWindowsStepView.swift */; };
CC96D3416E044B750C99B625 /* WeekScheduleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FCDE0D7D1609EC0DB3DD134 /* WeekScheduleUITests.swift */; };
CD174669421FE1FD4315F6E0 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BB0D740B79B9E129A3C2F382 /* WidgetKit.framework */; }; CD174669421FE1FD4315F6E0 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BB0D740B79B9E129A3C2F382 /* WidgetKit.framework */; };
CD2ADCDADFEC2D562E23D8E6 /* AutocompleteEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7AB2ECCA7D330E50AC549E /* AutocompleteEngine.swift */; }; CD2ADCDADFEC2D562E23D8E6 /* AutocompleteEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7AB2ECCA7D330E50AC549E /* AutocompleteEngine.swift */; };
D043E5DEA954AADC016133BF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6971B29A22117CC7621DB938 /* Foundation.framework */; }; D043E5DEA954AADC016133BF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6971B29A22117CC7621DB938 /* Foundation.framework */; };
@@ -203,6 +207,7 @@
20F23BF9788A3217E3D63721 /* HapticManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticManager.swift; sourceTree = "<group>"; }; 20F23BF9788A3217E3D63721 /* HapticManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticManager.swift; sourceTree = "<group>"; };
212FF80B5B9E9E5243CA79D5 /* MealMoodUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MealMoodUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 212FF80B5B9E9E5243CA79D5 /* MealMoodUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MealMoodUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
23957C735075B0DFB1DF51D7 /* DishDrawerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DishDrawerView.swift; sourceTree = "<group>"; }; 23957C735075B0DFB1DF51D7 /* DishDrawerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DishDrawerView.swift; sourceTree = "<group>"; };
27337A07289450A1D494AC02 /* WeekSchedule.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WeekSchedule.swift; sourceTree = "<group>"; };
2C20D9FC522FEBF7206CFD32 /* MealMoodApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealMoodApp.swift; sourceTree = "<group>"; }; 2C20D9FC522FEBF7206CFD32 /* MealMoodApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealMoodApp.swift; sourceTree = "<group>"; };
2C5CD466A50C339519544347 /* TagRulesEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagRulesEditView.swift; sourceTree = "<group>"; }; 2C5CD466A50C339519544347 /* TagRulesEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagRulesEditView.swift; sourceTree = "<group>"; };
2CF3C9FF75E9F24B32D54D5A /* MealMoodWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MealMoodWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2CF3C9FF75E9F24B32D54D5A /* MealMoodWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MealMoodWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -231,6 +236,7 @@
6CFA7D73F403353774761AD1 /* MealMoodWatchWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MealMoodWatchWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 6CFA7D73F403353774761AD1 /* MealMoodWatchWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MealMoodWatchWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
6D718B2E4F7E6D22E502F0A6 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; }; 6D718B2E4F7E6D22E502F0A6 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
6EA1948B5DAEEEA09B78C8A9 /* SecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryButton.swift; sourceTree = "<group>"; }; 6EA1948B5DAEEEA09B78C8A9 /* SecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryButton.swift; sourceTree = "<group>"; };
6FCDE0D7D1609EC0DB3DD134 /* WeekScheduleUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WeekScheduleUITests.swift; sourceTree = "<group>"; };
70D140A6E835CA6227C2D4D9 /* WelcomeStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeStepView.swift; sourceTree = "<group>"; }; 70D140A6E835CA6227C2D4D9 /* WelcomeStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeStepView.swift; sourceTree = "<group>"; };
71F0ADA350289AE410D572A7 /* IngredientGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IngredientGenerator.swift; sourceTree = "<group>"; }; 71F0ADA350289AE410D572A7 /* IngredientGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IngredientGenerator.swift; sourceTree = "<group>"; };
7603187713D390BD4D35770C /* AutocompleteEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteEngineTests.swift; sourceTree = "<group>"; }; 7603187713D390BD4D35770C /* AutocompleteEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteEngineTests.swift; sourceTree = "<group>"; };
@@ -242,6 +248,7 @@
89C47DD7B8F4B1441780E611 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; }; 89C47DD7B8F4B1441780E611 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
8CFA001E1BFA916ABC4C44C1 /* PremiumUpsellBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumUpsellBanner.swift; sourceTree = "<group>"; }; 8CFA001E1BFA916ABC4C44C1 /* PremiumUpsellBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumUpsellBanner.swift; sourceTree = "<group>"; };
8EE10285FEE9BE0684CC66A3 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; }; 8EE10285FEE9BE0684CC66A3 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
91C2BCF9D7A1458E045DA053 /* WeekScheduleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WeekScheduleTests.swift; sourceTree = "<group>"; };
928E6D4F6D78AD1840245FB4 /* PremiumSyncService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PremiumSyncService.swift; sourceTree = "<group>"; }; 928E6D4F6D78AD1840245FB4 /* PremiumSyncService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PremiumSyncService.swift; sourceTree = "<group>"; };
93B4B31A8F6E4D7AAB1B6A01 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; }; 93B4B31A8F6E4D7AAB1B6A01 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
965555A7774FF7EB975F0A07 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; }; 965555A7774FF7EB975F0A07 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
@@ -266,6 +273,7 @@
BB0D740B79B9E129A3C2F382 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; BB0D740B79B9E129A3C2F382 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
BED090ADA1FB259AD2AFEDE2 /* ICloudSyncService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ICloudSyncService.swift; sourceTree = "<group>"; }; BED090ADA1FB259AD2AFEDE2 /* ICloudSyncService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ICloudSyncService.swift; sourceTree = "<group>"; };
C2D1AAC0DB21EA5E2CCA38AD /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; C2D1AAC0DB21EA5E2CCA38AD /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
C4A46F6218891B47163E5DDA /* WeekScheduleSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WeekScheduleSheet.swift; sourceTree = "<group>"; };
C77C350D4A839B76A3D2EFE7 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; C77C350D4A839B76A3D2EFE7 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
C7B13F4D4D362E50CA65A0C1 /* MealMood.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MealMood.app; sourceTree = BUILT_PRODUCTS_DIR; }; C7B13F4D4D362E50CA65A0C1 /* MealMood.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MealMood.app; sourceTree = BUILT_PRODUCTS_DIR; };
C7C00EED19123F00AE87FF24 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; }; C7C00EED19123F00AE87FF24 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
@@ -372,6 +380,7 @@
69B309A43E486B5A723324BA /* WeekCalendarView.swift */, 69B309A43E486B5A723324BA /* WeekCalendarView.swift */,
2E113A29DEC0551C41AB7EBB /* WeekPlanShareView.swift */, 2E113A29DEC0551C41AB7EBB /* WeekPlanShareView.swift */,
1975C49FFC7D91AEB7932C97 /* SocialShareSheet.swift */, 1975C49FFC7D91AEB7932C97 /* SocialShareSheet.swift */,
C4A46F6218891B47163E5DDA /* WeekScheduleSheet.swift */,
); );
path = Home; path = Home;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -459,6 +468,7 @@
049AE68D5B577514ADD6BACB /* Tag.swift */, 049AE68D5B577514ADD6BACB /* Tag.swift */,
A23267BAA116E1214870416C /* WeekPlan.swift */, A23267BAA116E1214870416C /* WeekPlan.swift */,
3FC93C653B5540D7205BF294 /* ShoppingItem.swift */, 3FC93C653B5540D7205BF294 /* ShoppingItem.swift */,
27337A07289450A1D494AC02 /* WeekSchedule.swift */,
); );
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -496,6 +506,7 @@
17B976BF8B0BD6D1AA5F8C68 /* TagLocalizationTests.swift */, 17B976BF8B0BD6D1AA5F8C68 /* TagLocalizationTests.swift */,
62E2F6B2694CEC97AE3C1B13 /* PremiumSyncServiceTests.swift */, 62E2F6B2694CEC97AE3C1B13 /* PremiumSyncServiceTests.swift */,
69C0BDFEBA8B692530CB6B5E /* SpeechDictationServiceTests.swift */, 69C0BDFEBA8B692530CB6B5E /* SpeechDictationServiceTests.swift */,
91C2BCF9D7A1458E045DA053 /* WeekScheduleTests.swift */,
); );
path = MealMoodTests; path = MealMoodTests;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -584,6 +595,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
3CBD59AC26D5C6D138D67E35 /* OnboardingFlowUITests.swift */, 3CBD59AC26D5C6D138D67E35 /* OnboardingFlowUITests.swift */,
6FCDE0D7D1609EC0DB3DD134 /* WeekScheduleUITests.swift */,
); );
name = MealMoodUITests; name = MealMoodUITests;
path = MealMoodUITests; path = MealMoodUITests;
@@ -917,6 +929,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
8DB624B469178CFD8CD87672 /* OnboardingFlowUITests.swift in Sources */, 8DB624B469178CFD8CD87672 /* OnboardingFlowUITests.swift in Sources */,
CC96D3416E044B750C99B625 /* WeekScheduleUITests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -959,6 +972,7 @@
ACAEFA5F4C170C6438CD2652 /* TagLocalizationTests.swift in Sources */, ACAEFA5F4C170C6438CD2652 /* TagLocalizationTests.swift in Sources */,
2A58E7C96AF7C14EBFE1C65C /* PremiumSyncServiceTests.swift in Sources */, 2A58E7C96AF7C14EBFE1C65C /* PremiumSyncServiceTests.swift in Sources */,
64CC61B20F0AF602A6CC1114 /* SpeechDictationServiceTests.swift in Sources */, 64CC61B20F0AF602A6CC1114 /* SpeechDictationServiceTests.swift in Sources */,
A569F5FED382E8EABC471416 /* WeekScheduleTests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -1039,6 +1053,8 @@
6685C591F837FAFB29A322F7 /* DeduplicationService.swift in Sources */, 6685C591F837FAFB29A322F7 /* DeduplicationService.swift in Sources */,
BF89CD5E8CA0DFFC224CF94D /* WatchWeekPayload.swift in Sources */, BF89CD5E8CA0DFFC224CF94D /* WatchWeekPayload.swift in Sources */,
C6DBE6B5B4BE002A234CBCCA /* WatchSyncService.swift in Sources */, C6DBE6B5B4BE002A234CBCCA /* WatchSyncService.swift in Sources */,
8B12FAE3CC07AFB284094C61 /* WeekSchedule.swift in Sources */,
57F913037D2D381DFA1FB7DE /* WeekScheduleSheet.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
+37
View File
@@ -12,6 +12,13 @@ final class WeekPlan {
var updatedAt: Date = Date() var updatedAt: Date = Date()
var userRating: Int = 0 // 0 = unrated, 1 = liked, -1 = disliked var userRating: Int = 0 // 0 = unrated, 1 = liked, -1 = disliked
/// Per-week overrides of the global schedule (2.1.1, premium). nil = follow
/// `AppSettings`. Optional so existing stores lightweight-migrate and
/// CloudKit accepts the new attributes.
var includeWeekendsOverride: Bool?
/// Comma-separated MealType raw values, chronological. nil = follow settings.
var mealTypesOverrideRaw: String?
@Relationship(deleteRule: .cascade) @Relationship(deleteRule: .cascade)
var slots: [MealSlot]? = [] var slots: [MealSlot]? = []
@@ -21,6 +28,36 @@ final class WeekPlan {
set { slots = newValue } set { slots = newValue }
} }
/// The meal types this week overrides the global setting with, or nil when
/// it follows `AppSettings.activeMealTypes`.
var mealTypesOverride: [MealType]? {
get {
guard let raw = mealTypesOverrideRaw else { return nil }
let types = raw.split(separator: ",").compactMap { MealType(rawValue: String($0)) }
guard !types.isEmpty else { return nil }
return MealType.allCases.filter { types.contains($0) }
}
set {
guard let newValue else {
mealTypesOverrideRaw = nil
return
}
let ordered = MealType.allCases.filter { newValue.contains($0) }
// An empty selection would leave the week with no slots at all;
// treat it as "no override" instead.
mealTypesOverrideRaw = ordered.isEmpty ? nil : ordered.map(\.rawValue).joined(separator: ",")
}
}
var hasScheduleOverride: Bool {
includeWeekendsOverride != nil || mealTypesOverrideRaw != nil
}
func clearScheduleOverride() {
includeWeekendsOverride = nil
mealTypesOverrideRaw = nil
}
init( init(
id: UUID = UUID(), id: UUID = UUID(),
weekStartDate: Date, weekStartDate: Date,
+38
View File
@@ -0,0 +1,38 @@
import Foundation
/// Which days and which meals a given week is planned with. Until 2.1.1 this
/// came straight from `AppSettings` and applied to the whole calendar; a week
/// can now override it (premium), e.g. add the weekend for a trip or turn on
/// breakfast just for the school week.
struct WeekSchedule: Equatable {
let mealTypes: [MealType]
let includeWeekends: Bool
/// True when the values come from a per-week override rather than settings.
let isCustomized: Bool
var dayRange: ClosedRange<Int> { includeWeekends ? 0...6 : 0...4 }
var lastDay: Int { includeWeekends ? 6 : 4 }
}
extension AppSettings {
/// The schedule applied to every week that has no override of its own.
var defaultSchedule: WeekSchedule {
WeekSchedule(mealTypes: activeMealTypes, includeWeekends: includeWeekends, isCustomized: false)
}
/// Resolved schedule for a week: its own override when present, the global
/// settings otherwise.
///
/// Existing overrides are honoured even when `isPremium` is false a lapsed
/// subscription must not silently delete slots the user already filled.
/// Creating or editing an override is what premium gates (see
/// `PremiumAccess.canCustomizeWeekSchedule`).
func schedule(for plan: WeekPlan?) -> WeekSchedule {
guard let plan, plan.hasScheduleOverride else { return defaultSchedule }
return WeekSchedule(
mealTypes: plan.mealTypesOverride ?? activeMealTypes,
includeWeekends: plan.includeWeekendsOverride ?? includeWeekends,
isCustomized: true
)
}
}
@@ -462,3 +462,19 @@
"stats_dishes_per_tag_title" = "Gerichte pro Tag"; "stats_dishes_per_tag_title" = "Gerichte pro Tag";
"violations_banner_title" = "%d Mahlzeit(en) verletzen deine Regeln"; "violations_banner_title" = "%d Mahlzeit(en) verletzen deine Regeln";
"violations_banner_cta" = "Ansehen"; "violations_banner_cta" = "Ansehen";
/* Per-week schedule (2.1.1, premium) */
"week_schedule_title" = "Nur diese Woche";
"week_schedule_intro" = "Ändere Tage und Mahlzeiten dieser einen Woche, ohne deine allgemeinen Einstellungen anzurühren.";
"week_schedule_premium_message" = "Eine einzelne Woche anzupassen ist Premium";
"week_schedule_meals_section" = "Mahlzeiten dieser Woche";
"week_schedule_meals_footer" = "Aktiviere Frühstück oder Snack nur für diese Woche. Mindestens eine Mahlzeit muss aktiv bleiben.";
"week_schedule_days_section" = "Tage dieser Woche";
"week_schedule_apply" = "Anwenden";
"week_schedule_reset" = "Zurück zu meinen allgemeinen Einstellungen";
"week_schedule_reset_footer" = "Diese Woche folgt wieder deinen allgemeinen Einstellungen.";
"week_schedule_warning_title" = "Geplante Mahlzeiten gehen verloren";
"week_schedule_warning_message" = "Wenn du diese Tage oder Mahlzeiten entfernst, werden %d bereits geplante Mahlzeiten dieser Woche gelöscht.";
"week_schedule_warning_confirm" = "Trotzdem entfernen";
"toast_week_schedule_updated" = "Woche aktualisiert";
"toast_week_schedule_reset" = "Woche folgt wieder deinen Einstellungen";
@@ -462,3 +462,19 @@
"stats_dishes_per_tag_title" = "Dishes per tag"; "stats_dishes_per_tag_title" = "Dishes per tag";
"violations_banner_title" = "%d meal(s) break your rules"; "violations_banner_title" = "%d meal(s) break your rules";
"violations_banner_cta" = "View"; "violations_banner_cta" = "View";
/* Per-week schedule (2.1.1, premium) */
"week_schedule_title" = "This week only";
"week_schedule_intro" = "Change the days and meals of this one week, without touching your general settings.";
"week_schedule_premium_message" = "Customising a single week is Premium";
"week_schedule_meals_section" = "Meals this week";
"week_schedule_meals_footer" = "Turn breakfast or a snack on just for this week. At least one meal must stay on.";
"week_schedule_days_section" = "Days this week";
"week_schedule_apply" = "Apply";
"week_schedule_reset" = "Back to my general settings";
"week_schedule_reset_footer" = "This week will follow your general settings again.";
"week_schedule_warning_title" = "Planned meals will be lost";
"week_schedule_warning_message" = "Removing those days or meals will delete %d meals already planned this week.";
"week_schedule_warning_confirm" = "Remove anyway";
"toast_week_schedule_updated" = "Week updated";
"toast_week_schedule_reset" = "Week back to your general settings";
@@ -462,3 +462,19 @@
"stats_dishes_per_tag_title" = "Platos por etiqueta"; "stats_dishes_per_tag_title" = "Platos por etiqueta";
"violations_banner_title" = "%d comida(s) incumplen tus reglas"; "violations_banner_title" = "%d comida(s) incumplen tus reglas";
"violations_banner_cta" = "Ver"; "violations_banner_cta" = "Ver";
/* Per-week schedule (2.1.1, premium) */
"week_schedule_title" = "Solo esta semana";
"week_schedule_intro" = "Cambia los días y las comidas de esta semana concreta, sin tocar tu configuración general.";
"week_schedule_premium_message" = "Personalizar una semana suelta es Premium";
"week_schedule_meals_section" = "Comidas de esta semana";
"week_schedule_meals_footer" = "Activa el desayuno o la merienda solo para esta semana. Debe quedar al menos una comida.";
"week_schedule_days_section" = "Días de esta semana";
"week_schedule_apply" = "Aplicar";
"week_schedule_reset" = "Volver a mi configuración general";
"week_schedule_reset_footer" = "Esta semana pasará a seguir de nuevo los ajustes generales.";
"week_schedule_warning_title" = "Se perderán comidas planificadas";
"week_schedule_warning_message" = "Al quitar esos días o comidas se borrarán %d comidas ya planificadas de esta semana.";
"week_schedule_warning_confirm" = "Quitar igualmente";
"toast_week_schedule_updated" = "Semana actualizada";
"toast_week_schedule_reset" = "Semana con tu configuración general";
@@ -462,3 +462,19 @@
"stats_dishes_per_tag_title" = "Plats par étiquette"; "stats_dishes_per_tag_title" = "Plats par étiquette";
"violations_banner_title" = "%d repas ne respecte(nt) pas vos règles"; "violations_banner_title" = "%d repas ne respecte(nt) pas vos règles";
"violations_banner_cta" = "Voir"; "violations_banner_cta" = "Voir";
/* Per-week schedule (2.1.1, premium) */
"week_schedule_title" = "Cette semaine seulement";
"week_schedule_intro" = "Modifiez les jours et les repas de cette semaine précise, sans toucher à vos réglages généraux.";
"week_schedule_premium_message" = "Personnaliser une seule semaine est réservé au Premium";
"week_schedule_meals_section" = "Repas de cette semaine";
"week_schedule_meals_footer" = "Activez le petit-déjeuner ou le goûter juste pour cette semaine. Au moins un repas doit rester actif.";
"week_schedule_days_section" = "Jours de cette semaine";
"week_schedule_apply" = "Appliquer";
"week_schedule_reset" = "Revenir à mes réglages généraux";
"week_schedule_reset_footer" = "Cette semaine suivra de nouveau vos réglages généraux.";
"week_schedule_warning_title" = "Des repas planifiés seront perdus";
"week_schedule_warning_message" = "En retirant ces jours ou ces repas, %d repas déjà planifiés cette semaine seront supprimés.";
"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";
@@ -462,3 +462,19 @@
"stats_dishes_per_tag_title" = "Piatti per etichetta"; "stats_dishes_per_tag_title" = "Piatti per etichetta";
"violations_banner_title" = "%d pasto/i non rispettano le tue regole"; "violations_banner_title" = "%d pasto/i non rispettano le tue regole";
"violations_banner_cta" = "Vedi"; "violations_banner_cta" = "Vedi";
/* Per-week schedule (2.1.1, premium) */
"week_schedule_title" = "Solo questa settimana";
"week_schedule_intro" = "Cambia i giorni e i pasti di questa singola settimana, senza toccare le impostazioni generali.";
"week_schedule_premium_message" = "Personalizzare una singola settimana è Premium";
"week_schedule_meals_section" = "Pasti di questa settimana";
"week_schedule_meals_footer" = "Attiva colazione o merenda solo per questa settimana. Deve restare almeno un pasto.";
"week_schedule_days_section" = "Giorni di questa settimana";
"week_schedule_apply" = "Applica";
"week_schedule_reset" = "Torna alle impostazioni generali";
"week_schedule_reset_footer" = "Questa settimana seguirà di nuovo le impostazioni generali.";
"week_schedule_warning_title" = "Perderai dei pasti pianificati";
"week_schedule_warning_message" = "Rimuovendo quei giorni o quei pasti verranno eliminati %d pasti già pianificati in questa settimana.";
"week_schedule_warning_confirm" = "Rimuovi comunque";
"toast_week_schedule_updated" = "Settimana aggiornata";
"toast_week_schedule_reset" = "Settimana tornata alle impostazioni generali";
@@ -462,3 +462,19 @@
"stats_dishes_per_tag_title" = "Pratos por etiqueta"; "stats_dishes_per_tag_title" = "Pratos por etiqueta";
"violations_banner_title" = "%d refeição(ões) quebram suas regras"; "violations_banner_title" = "%d refeição(ões) quebram suas regras";
"violations_banner_cta" = "Ver"; "violations_banner_cta" = "Ver";
/* Per-week schedule (2.1.1, premium) */
"week_schedule_title" = "Só esta semana";
"week_schedule_intro" = "Mude os dias e as refeições desta semana específica, sem alterar suas configurações gerais.";
"week_schedule_premium_message" = "Personalizar uma semana específica é Premium";
"week_schedule_meals_section" = "Refeições desta semana";
"week_schedule_meals_footer" = "Ative o café da manhã ou o lanche só para esta semana. Pelo menos uma refeição precisa ficar ativa.";
"week_schedule_days_section" = "Dias desta semana";
"week_schedule_apply" = "Aplicar";
"week_schedule_reset" = "Voltar às configurações gerais";
"week_schedule_reset_footer" = "Esta semana voltará a seguir suas configurações gerais.";
"week_schedule_warning_title" = "Refeições planejadas serão perdidas";
"week_schedule_warning_message" = "Ao remover esses dias ou refeições, %d refeições já planejadas nesta semana serão apagadas.";
"week_schedule_warning_confirm" = "Remover mesmo assim";
"toast_week_schedule_updated" = "Semana atualizada";
"toast_week_schedule_reset" = "Semana de volta às configurações gerais";
+4 -2
View File
@@ -47,8 +47,10 @@ struct DefaultDataService {
let plan = WeekPlan(weekStartDate: weekStart) let plan = WeekPlan(weekStartDate: weekStart)
context.insert(plan) context.insert(plan)
let maxDay = settings.includeWeekends ? 6 : 4 // A brand-new plan has no override yet, so this is the global schedule.
let mealTypes: [String] = settings.activeMealTypes.map(\.rawValue) let schedule = settings.schedule(for: plan)
let maxDay = schedule.lastDay
let mealTypes: [String] = schedule.mealTypes.map(\.rawValue)
for day in 0...maxDay { for day in 0...maxDay {
for mealType in mealTypes { for mealType in mealTypes {
@@ -109,6 +109,8 @@ final class ICloudSyncService {
weekStartDate: plan.weekStartDate, weekStartDate: plan.weekStartDate,
createdAt: plan.createdAt, createdAt: plan.createdAt,
updatedAt: plan.updatedAt, updatedAt: plan.updatedAt,
includeWeekendsOverride: plan.includeWeekendsOverride,
mealTypesOverrideRaw: plan.mealTypesOverrideRaw,
slots: plan.slotList.map { slots: plan.slotList.map {
MealSlotPayload( MealSlotPayload(
id: $0.id, id: $0.id,
@@ -206,6 +208,8 @@ final class ICloudSyncService {
createdAt: payload.createdAt, createdAt: payload.createdAt,
updatedAt: payload.updatedAt updatedAt: payload.updatedAt
) )
plan.includeWeekendsOverride = payload.includeWeekendsOverride
plan.mealTypesOverrideRaw = payload.mealTypesOverrideRaw
context.insert(plan) context.insert(plan)
payload.slots.forEach { slotPayload in payload.slots.forEach { slotPayload in
@@ -289,6 +293,10 @@ private struct WeekPlanPayload: Codable {
let weekStartDate: Date let weekStartDate: Date
let createdAt: Date let createdAt: Date
let updatedAt: Date let updatedAt: Date
// Per-week schedule overrides (2.1.1). Absent in older snapshots, and nil
// whenever the week just follows the global settings.
var includeWeekendsOverride: Bool?
var mealTypesOverrideRaw: String?
let slots: [MealSlotPayload] let slots: [MealSlotPayload]
} }
+5
View File
@@ -13,6 +13,11 @@ enum PremiumAccess {
!isPremium && dishCount >= freeDishLimit !isPremium && dishCount >= freeDishLimit
} }
/// Per-week schedule overrides (weekend on/off, which meals) are premium.
static func canCustomizeWeekSchedule(isPremium: Bool) -> Bool {
isPremium
}
static func canGenerateIngredients(isPremium: Bool) -> Bool { static func canGenerateIngredients(isPremium: Bool) -> Bool {
isPremium || UserDefaults.standard.integer(forKey: generationCountKey) < freeIngredientGenerations isPremium || UserDefaults.standard.integer(forKey: generationCountKey) < freeIngredientGenerations
} }
+3 -3
View File
@@ -52,10 +52,10 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable {
let todayOffset = (Calendar.current.component(.weekday, from: Date()) + 5) % 7 let todayOffset = (Calendar.current.component(.weekday, from: Date()) + 5) % 7
let isCurrentWeek = plan.weekStartDate == Date().startOfWeek() let isCurrentWeek = plan.weekStartDate == Date().startOfWeek()
let range = settings.includeWeekends ? 0...6 : 0...4 let schedule = settings.schedule(for: plan)
let days: [WatchWeekPayload.Day] = range.map { day in let days: [WatchWeekPayload.Day] = schedule.dayRange.map { day in
let meals: [WatchWeekPayload.Meal] = settings.activeMealTypes.map { meal in let meals: [WatchWeekPayload.Meal] = schedule.mealTypes.map { meal in
let slot = plan.slotList.first { $0.dayOfWeek == day && $0.mealType == meal.rawValue } let slot = plan.slotList.first { $0.dayOfWeek == day && $0.mealType == meal.rawValue }
let label = String(localized: String.LocalizationValue(meal.localizedKey)) let label = String(localized: String.LocalizationValue(meal.localizedKey))
var name: String? var name: String?
+1 -1
View File
@@ -25,7 +25,7 @@ enum WidgetDataStore {
// weekday: 1=Sun,2=Mon..7=Sat appDayOfWeek: 0=Mon..6=Sun // weekday: 1=Sun,2=Mon..7=Sat appDayOfWeek: 0=Mon..6=Sun
let appDayOfWeek = (weekday + 5) % 7 let appDayOfWeek = (weekday + 5) % 7
let meals: [TodayMealData.Meal] = settings.activeMealTypes.map { mealType in let meals: [TodayMealData.Meal] = settings.schedule(for: plan).mealTypes.map { mealType in
let slot = plan?.slotList.first { let slot = plan?.slotList.first {
$0.dayOfWeek == appDayOfWeek && $0.mealType == mealType.rawValue $0.dayOfWeek == appDayOfWeek && $0.mealType == mealType.rawValue
} }
+50 -2
View File
@@ -555,14 +555,62 @@ final class HomeViewModel: ObservableObject {
HapticManager.shared.notification(type: .warning) HapticManager.shared.notification(type: .warning)
} }
/// Applies a per-week schedule override (premium). Falls back to clearing
/// the override when the choice matches the global settings, so the week
/// keeps following them if the user changes settings later.
func applyWeekSchedule(
includeWeekends: Bool,
mealTypes: [MealType],
plan: WeekPlan,
settings: AppSettings,
context: ModelContext
) {
let global = settings.defaultSchedule
let matchesGlobal = includeWeekends == global.includeWeekends && mealTypes == global.mealTypes
if matchesGlobal {
plan.clearScheduleOverride()
} else {
plan.includeWeekendsOverride = includeWeekends
plan.mealTypesOverride = mealTypes
}
syncSlotsIfNeeded(plan: plan, settings: settings, context: context)
plan.updatedAt = Date()
try? context.save()
applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false)
AnalyticsService.logEvent("week_schedule_customized", parameters: [
"include_weekends": includeWeekends,
"meal_count": mealTypes.count,
"matches_global": matchesGlobal
])
HapticManager.shared.notification(type: .success)
showToastMessage(localizedString("toast_week_schedule_updated", language: settings.languageEnum.resolved()))
}
func resetWeekSchedule(plan: WeekPlan, settings: AppSettings, context: ModelContext) {
guard plan.hasScheduleOverride else { return }
plan.clearScheduleOverride()
syncSlotsIfNeeded(plan: plan, settings: settings, context: context)
plan.updatedAt = Date()
try? context.save()
applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false)
AnalyticsService.logEvent("week_schedule_reset")
HapticManager.shared.impact(style: .light)
showToastMessage(localizedString("toast_week_schedule_reset", language: settings.languageEnum.resolved()))
}
private struct SlotKey: Hashable { private struct SlotKey: Hashable {
let dayOfWeek: Int let dayOfWeek: Int
let mealType: String let mealType: String
} }
private func syncSlotsIfNeeded(plan: WeekPlan, settings: AppSettings, context: ModelContext) { private func syncSlotsIfNeeded(plan: WeekPlan, settings: AppSettings, context: ModelContext) {
let maxDay = settings.includeWeekends ? 6 : 4 let schedule = settings.schedule(for: plan)
let mealTypes: [String] = settings.activeMealTypes.map(\.rawValue) let maxDay = schedule.lastDay
let mealTypes: [String] = schedule.mealTypes.map(\.rawValue)
// Clean up any legacy duplicates for the same day+mealType key to keep UI mapping stable. // Clean up any legacy duplicates for the same day+mealType key to keep UI mapping stable.
var groupedByKey: [SlotKey: [MealSlot]] = [:] var groupedByKey: [SlotKey: [MealSlot]] = [:]
+55 -2
View File
@@ -24,6 +24,7 @@ struct HomeView: View {
@State private var selectedEmptySlotId: UUID? @State private var selectedEmptySlotId: UUID?
@State private var selectedFilledSlotId: UUID? @State private var selectedFilledSlotId: UUID?
@State private var showWeekPicker: Bool = false @State private var showWeekPicker: Bool = false
@State private var showWeekSchedule: Bool = false
@State private var weekPickerDate: Date = Date() @State private var weekPickerDate: Date = Date()
@State private var showCopyPreviousConfirm: Bool = false @State private var showCopyPreviousConfirm: Bool = false
@State private var copyPreviousSource: String = "menu" @State private var copyPreviousSource: String = "menu"
@@ -254,7 +255,7 @@ struct HomeView: View {
/// Week switcher. It used to live in the navigation bar's principal slot, /// Week switcher. It used to live in the navigation bar's principal slot,
/// where a premium user's four leading buttons squeezed it until the arrows /// where a premium user's four leading buttons squeezed it until the arrows
/// were clipped away leaving no visible way to change week. /// were clipped away leaving no visible way to change week.
private func weekSwitcher(settings: AppSettings) -> some View { private func weekSwitcher(plan: WeekPlan, settings: AppSettings) -> some View {
HStack(spacing: 12) { HStack(spacing: 12) {
Button { Button {
viewModel.goToPreviousWeek() viewModel.goToPreviousWeek()
@@ -311,6 +312,34 @@ struct HomeView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(Text("home_next")) .accessibilityLabel(Text("home_next"))
// Per-week schedule (premium): weekend on/off and which meals, for
// this week only. Free users get the sheet with the upsell inside.
if viewModel.canEditCurrentWeek {
Button {
showWeekSchedule = true
AnalyticsService.logEvent("week_schedule_opened", parameters: [
"is_premium": settings.isPremium
])
} label: {
Image(systemName: "slider.horizontal.3")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.mealMoodCoral)
.frame(width: 36, height: 36)
.background(Circle().fill(Color.white))
.overlay(Circle().stroke(Color.mealMoodCoral.opacity(0.35), lineWidth: 1))
.overlay(alignment: .topTrailing) {
if plan.hasScheduleOverride {
Circle()
.fill(Color.mealMoodCoral)
.frame(width: 9, height: 9)
.overlay(Circle().stroke(Color.mealMoodBackground, lineWidth: 1.5))
}
}
}
.buttonStyle(.plain)
.accessibilityLabel(Text("week_schedule_title"))
}
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 6) .padding(.top, 6)
@@ -380,7 +409,7 @@ struct HomeView: View {
@ViewBuilder @ViewBuilder
private func mainContent(plan: WeekPlan, settings: AppSettings) -> some View { private func mainContent(plan: WeekPlan, settings: AppSettings) -> some View {
VStack(spacing: 0) { VStack(spacing: 0) {
weekSwitcher(settings: settings) weekSwitcher(plan: plan, settings: settings)
violationsBanner(plan: plan) violationsBanner(plan: plan)
@@ -577,6 +606,30 @@ struct HomeView: View {
} }
.presentationDetents([.medium, .large]) .presentationDetents([.medium, .large])
} }
.sheet(isPresented: $showWeekSchedule) {
WeekScheduleSheet(
plan: plan,
settings: settings,
onApply: { includeWeekends, mealTypes in
viewModel.applyWeekSchedule(
includeWeekends: includeWeekends,
mealTypes: mealTypes,
plan: plan,
settings: settings,
context: context
)
},
onReset: {
viewModel.resetWeekSchedule(plan: plan, settings: settings, context: context)
},
onUpsell: {
showWeekSchedule = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
paywallPresentation = PaywallPresentation(source: "week_schedule")
}
}
)
}
.sheet( .sheet(
isPresented: Binding( isPresented: Binding(
get: { selectedEmptySlotId != nil }, get: { selectedEmptySlotId != nil },
+12 -2
View File
@@ -13,8 +13,12 @@ struct WeekCalendarView: View {
var onTapEmptySlot: ((MealSlot) -> Void)? var onTapEmptySlot: ((MealSlot) -> Void)?
var onTapFilledSlot: ((MealSlot) -> Void)? var onTapFilledSlot: ((MealSlot) -> Void)?
private var schedule: WeekSchedule {
settings.schedule(for: plan)
}
private var dayRange: ClosedRange<Int> { private var dayRange: ClosedRange<Int> {
settings.includeWeekends ? 0...6 : 0...4 schedule.dayRange
} }
private var days: [Int] { private var days: [Int] {
@@ -22,7 +26,7 @@ struct WeekCalendarView: View {
} }
private var mealTypes: [MealType] { private var mealTypes: [MealType] {
settings.activeMealTypes schedule.mealTypes
} }
var body: some View { var body: some View {
@@ -50,6 +54,12 @@ struct WeekCalendarView: View {
.onChange(of: settings.mealWindows) { _, _ in .onChange(of: settings.mealWindows) { _, _ in
refreshDisplaySlots() refreshDisplaySlots()
} }
.onChange(of: plan.includeWeekendsOverride) { _, _ in
refreshDisplaySlots()
}
.onChange(of: plan.mealTypesOverrideRaw) { _, _ in
refreshDisplaySlots()
}
} }
private var compactLayout: some View { private var compactLayout: some View {
+5 -5
View File
@@ -15,9 +15,9 @@ enum WeekPlanTextExporter {
lines.append("🍽️ *\(String(localized: "share_week_title")) · \(plan.weekStartDate.formattedWeekRange())*") lines.append("🍽️ *\(String(localized: "share_week_title")) · \(plan.weekStartDate.formattedWeekRange())*")
lines.append("") lines.append("")
let dayRange = settings.includeWeekends ? 0...6 : 0...4 let schedule = settings.schedule(for: plan)
for day in dayRange { for day in schedule.dayRange {
let mealLines = settings.activeMealTypes.compactMap { meal -> String? in let mealLines = schedule.mealTypes.compactMap { meal -> String? in
guard let slot = plan.slotList.first(where: { $0.dayOfWeek == day && $0.mealType == meal.rawValue }) else { return nil } guard let slot = plan.slotList.first(where: { $0.dayOfWeek == day && $0.mealType == meal.rawValue }) else { return nil }
let label = String(localized: String.LocalizationValue(meal.localizedKey)) let label = String(localized: String.LocalizationValue(meal.localizedKey))
if slot.isEatingOut { if slot.isEatingOut {
@@ -96,11 +96,11 @@ struct WeekPlanShareView: View {
} }
private var dayRange: ClosedRange<Int> { private var dayRange: ClosedRange<Int> {
settings.includeWeekends ? 0...6 : 0...4 settings.schedule(for: plan).dayRange
} }
private var mealTypes: [MealType] { private var mealTypes: [MealType] {
settings.activeMealTypes settings.schedule(for: plan).mealTypes
} }
private var exportStyle: WeekExportStyle { private var exportStyle: WeekExportStyle {
+172
View File
@@ -0,0 +1,172 @@
import SwiftUI
/// "Just this week" (premium): adds the weekend or turns meals on/off for a
/// single week without touching the global settings. Removing a day or a meal
/// deletes its slots, so anything already planned there is confirmed first.
struct WeekScheduleSheet: View {
let plan: WeekPlan
let settings: AppSettings
let onApply: (_ includeWeekends: Bool, _ mealTypes: [MealType]) -> Void
let onReset: () -> Void
let onUpsell: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var includeWeekends: Bool
@State private var selectedMealTypes: Set<MealType>
@State private var showDestructiveConfirm: Bool = false
private var canEdit: Bool {
PremiumAccess.canCustomizeWeekSchedule(isPremium: settings.isPremium)
}
init(
plan: WeekPlan,
settings: AppSettings,
onApply: @escaping (_ includeWeekends: Bool, _ mealTypes: [MealType]) -> Void,
onReset: @escaping () -> Void,
onUpsell: @escaping () -> Void
) {
self.plan = plan
self.settings = settings
self.onApply = onApply
self.onReset = onReset
self.onUpsell = onUpsell
let schedule = settings.schedule(for: plan)
_includeWeekends = State(initialValue: schedule.includeWeekends)
_selectedMealTypes = State(initialValue: Set(schedule.mealTypes))
}
/// Slots that would be deleted by the current selection because their day
/// or meal is no longer planned and that already hold something.
private var slotsAtRisk: [MealSlot] {
let lastDay = includeWeekends ? 6 : 4
return plan.slotList.filter { slot in
let dropped = slot.dayOfWeek > lastDay || !selectedMealTypes.contains(slot.mealTypeEnum)
let hasContent = slot.dishId != nil || slot.isEatingOut || slot.isSkipped
return dropped && hasContent
}
}
private var hasChanges: Bool {
let current = settings.schedule(for: plan)
return includeWeekends != current.includeWeekends || selectedMealTypes != Set(current.mealTypes)
}
var body: some View {
NavigationStack {
Form {
Section {
Text("week_schedule_intro")
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
if !canEdit {
PremiumUpsellBanner(
messageKey: "week_schedule_premium_message",
actionTitleKey: "premium_subscribe",
action: onUpsell
)
.listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 6, trailing: 8))
}
} header: {
Text(plan.weekStartDate.formattedWeekRange())
}
.listRowBackground(Color.mealMoodSurface)
Section {
ForEach(MealType.allCases, id: \.self) { mealType in
Toggle(isOn: Binding(
get: { selectedMealTypes.contains(mealType) },
set: { isOn in
if isOn {
selectedMealTypes.insert(mealType)
} else if selectedMealTypes.count > 1 {
selectedMealTypes.remove(mealType)
}
}
)) {
Label {
Text(String(localized: String.LocalizationValue(mealType.localizedKey)))
} icon: {
Image(systemName: mealType.icon)
.foregroundStyle(Color.mealMoodCoral)
}
}
.disabled(!canEdit)
}
} header: {
Text("week_schedule_meals_section")
} footer: {
Text("week_schedule_meals_footer")
}
.listRowBackground(Color.mealMoodSurface)
Section {
Toggle(isOn: $includeWeekends) {
Label {
Text("settings_include_weekends")
} icon: {
Image(systemName: "calendar")
.foregroundStyle(Color.mealMoodCoral)
}
}
.disabled(!canEdit)
} header: {
Text("week_schedule_days_section")
}
.listRowBackground(Color.mealMoodSurface)
if plan.hasScheduleOverride {
Section {
Button(role: .destructive) {
onReset()
dismiss()
} label: {
Label("week_schedule_reset", systemImage: "arrow.counterclockwise")
}
.disabled(!canEdit)
} footer: {
Text("week_schedule_reset_footer")
}
.listRowBackground(Color.mealMoodSurface)
}
}
.scrollContentBackground(.hidden)
.background(Color.mealMoodBackground.ignoresSafeArea())
.tint(.mealMoodCoral)
.navigationTitle("week_schedule_title")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("reset_cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("week_schedule_apply") {
if slotsAtRisk.isEmpty {
apply()
} else {
showDestructiveConfirm = true
}
}
.disabled(!canEdit || !hasChanges)
}
}
.alert("week_schedule_warning_title", isPresented: $showDestructiveConfirm) {
Button("reset_cancel", role: .cancel) {}
Button("week_schedule_warning_confirm", role: .destructive) { apply() }
} message: {
Text(String(format: String(localized: "week_schedule_warning_message"), slotsAtRisk.count))
}
}
// Full height: at medium the weekend toggle and the reset button fall
// below the fold, which is exactly what the sheet is for.
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
private func apply() {
onApply(includeWeekends, MealType.allCases.filter { selectedMealTypes.contains($0) })
dismiss()
}
}
+235
View File
@@ -0,0 +1,235 @@
import XCTest
import SwiftData
@testable import MealMood
final class WeekScheduleTests: XCTestCase {
private func makeSettings(
mealTypes: [MealType] = [.lunch, .dinner],
includeWeekends: Bool = false
) -> AppSettings {
let settings = AppSettings()
settings.activeMealTypes = mealTypes
settings.includeWeekends = includeWeekends
return settings
}
// MARK: - Falling back to the global settings
func testScheduleWithoutOverride_followsSettings() {
let settings = makeSettings(mealTypes: [.dinner], includeWeekends: false)
let plan = WeekPlan(weekStartDate: Date())
let schedule = settings.schedule(for: plan)
XCTAssertEqual(schedule.mealTypes, [.dinner])
XCTAssertFalse(schedule.includeWeekends)
XCTAssertFalse(schedule.isCustomized)
XCTAssertEqual(schedule.dayRange, 0...4)
XCTAssertEqual(schedule.lastDay, 4)
}
func testScheduleForNilPlan_followsSettings() {
let settings = makeSettings(mealTypes: [.lunch, .dinner], includeWeekends: true)
let schedule = settings.schedule(for: nil)
XCTAssertEqual(schedule.mealTypes, [.lunch, .dinner])
XCTAssertTrue(schedule.includeWeekends)
XCTAssertEqual(schedule.dayRange, 0...6)
}
// MARK: - Per-week overrides
func testWeekendOverride_onlyAffectsThatWeek() {
let settings = makeSettings(includeWeekends: false)
let plan = WeekPlan(weekStartDate: Date())
let otherPlan = WeekPlan(weekStartDate: Date().addingDays(7))
plan.includeWeekendsOverride = true
XCTAssertTrue(settings.schedule(for: plan).includeWeekends)
XCTAssertEqual(settings.schedule(for: plan).dayRange, 0...6)
XCTAssertTrue(settings.schedule(for: plan).isCustomized)
XCTAssertFalse(settings.schedule(for: otherPlan).includeWeekends)
XCTAssertFalse(settings.includeWeekends, "the global setting must stay untouched")
}
func testMealTypesOverride_replacesTheGlobalMeals() {
let settings = makeSettings(mealTypes: [.dinner])
let plan = WeekPlan(weekStartDate: Date())
plan.mealTypesOverride = [.dinner, .breakfast, .snack]
XCTAssertEqual(settings.schedule(for: plan).mealTypes, [.breakfast, .snack, .dinner],
"meals are stored in chronological order, not selection order")
XCTAssertEqual(settings.activeMealTypes, [.dinner])
}
func testPartialOverride_keepsSettingsForTheOtherAxis() {
let settings = makeSettings(mealTypes: [.lunch, .dinner], includeWeekends: false)
let plan = WeekPlan(weekStartDate: Date())
plan.includeWeekendsOverride = true
XCTAssertEqual(settings.schedule(for: plan).mealTypes, [.lunch, .dinner])
XCTAssertTrue(settings.schedule(for: plan).includeWeekends)
}
func testEmptyMealTypesOverride_isTreatedAsNoOverride() {
let settings = makeSettings(mealTypes: [.lunch])
let plan = WeekPlan(weekStartDate: Date())
plan.mealTypesOverride = []
XCTAssertNil(plan.mealTypesOverrideRaw)
XCTAssertFalse(plan.hasScheduleOverride)
XCTAssertEqual(settings.schedule(for: plan).mealTypes, [.lunch])
}
func testClearScheduleOverride_returnsToSettings() {
let settings = makeSettings(mealTypes: [.dinner], includeWeekends: false)
let plan = WeekPlan(weekStartDate: Date())
plan.includeWeekendsOverride = true
plan.mealTypesOverride = [.lunch, .dinner]
XCTAssertTrue(plan.hasScheduleOverride)
plan.clearScheduleOverride()
XCTAssertFalse(plan.hasScheduleOverride)
XCTAssertEqual(settings.schedule(for: plan).mealTypes, [.dinner])
XCTAssertFalse(settings.schedule(for: plan).includeWeekends)
}
// MARK: - Premium gating
func testOverrideIsHonouredEvenWhenNotPremium() {
// A lapsed subscription must not silently drop the slots the user
// already planned in a customised week.
let settings = makeSettings(includeWeekends: false)
settings.isPremium = false
let plan = WeekPlan(weekStartDate: Date())
plan.includeWeekendsOverride = true
XCTAssertTrue(settings.schedule(for: plan).includeWeekends)
XCTAssertFalse(PremiumAccess.canCustomizeWeekSchedule(isPremium: false))
XCTAssertTrue(PremiumAccess.canCustomizeWeekSchedule(isPremium: true))
}
}
@MainActor
final class WeekScheduleSlotSyncTests: 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!
override func tearDown() {
container = nil
super.tearDown()
}
private func makeContext() throws -> ModelContext {
container = try ModelContainer(
for: AppSettings.self, Dish.self, Tag.self, WeekPlan.self, MealSlot.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
return container.mainContext
}
func testApplyingAnOverrideAddsTheWeekendAndBreakfastSlots() throws {
let context = try makeContext()
let settings = AppSettings()
settings.isPremium = true
settings.activeMealTypes = [.lunch, .dinner]
settings.includeWeekends = false
context.insert(settings)
let monday = Date().startOfWeek()
let plan = DefaultDataService.createWeekPlan(for: monday, settings: settings, context: context)
XCTAssertEqual(plan.slotList.count, 10, "5 weekdays x 2 meals")
let viewModel = HomeViewModel()
viewModel.applyWeekSchedule(
includeWeekends: true,
mealTypes: [.breakfast, .lunch, .dinner],
plan: plan,
settings: settings,
context: context
)
XCTAssertEqual(plan.slotList.count, 21, "7 days x 3 meals")
XCTAssertTrue(plan.hasScheduleOverride)
XCTAssertTrue(plan.slotList.contains { $0.dayOfWeek == 6 && $0.mealTypeEnum == .breakfast })
// The global settings must be untouched this is a one-week change.
XCTAssertFalse(settings.includeWeekends)
XCTAssertEqual(settings.activeMealTypes, [.lunch, .dinner])
}
func testResettingTheOverrideRestoresTheGlobalSlots() throws {
let context = try makeContext()
let settings = AppSettings()
settings.isPremium = true
settings.activeMealTypes = [.dinner]
settings.includeWeekends = false
context.insert(settings)
let plan = DefaultDataService.createWeekPlan(for: Date().startOfWeek(), settings: settings, context: context)
let viewModel = HomeViewModel()
viewModel.applyWeekSchedule(
includeWeekends: true, mealTypes: [.lunch, .dinner],
plan: plan, settings: settings, context: context
)
XCTAssertEqual(plan.slotList.count, 14)
viewModel.resetWeekSchedule(plan: plan, settings: settings, context: context)
XCTAssertFalse(plan.hasScheduleOverride)
XCTAssertEqual(plan.slotList.count, 5, "back to 5 weekdays x dinner")
XCTAssertTrue(plan.slotList.allSatisfy { $0.mealTypeEnum == .dinner && $0.dayOfWeek <= 4 })
}
func testApplyingMatchingValuesClearsTheOverride() throws {
let context = try makeContext()
let settings = AppSettings()
settings.isPremium = true
settings.activeMealTypes = [.lunch, .dinner]
settings.includeWeekends = true
context.insert(settings)
let plan = DefaultDataService.createWeekPlan(for: Date().startOfWeek(), settings: settings, context: context)
let viewModel = HomeViewModel()
viewModel.applyWeekSchedule(
includeWeekends: true, mealTypes: [.lunch, .dinner],
plan: plan, settings: settings, context: context
)
XCTAssertFalse(plan.hasScheduleOverride,
"matching the global settings means the week keeps following them")
}
func testRemovingAMealDropsItsPlannedSlots() throws {
let context = try makeContext()
let settings = AppSettings()
settings.isPremium = true
settings.activeMealTypes = [.lunch, .dinner]
settings.includeWeekends = true
context.insert(settings)
let dish = Dish(name: "Tortilla")
context.insert(dish)
let plan = DefaultDataService.createWeekPlan(for: Date().startOfWeek(), settings: settings, context: context)
let saturdayLunch = try XCTUnwrap(plan.slotList.first { $0.dayOfWeek == 5 && $0.mealTypeEnum == .lunch })
saturdayLunch.dishId = dish.id
let viewModel = HomeViewModel()
viewModel.applyWeekSchedule(
includeWeekends: false, mealTypes: [.dinner],
plan: plan, settings: settings, context: context
)
XCTAssertEqual(plan.slotList.count, 5)
XCTAssertFalse(plan.slotList.contains { $0.mealTypeEnum == .lunch })
XCTAssertFalse(plan.slotList.contains { $0.dayOfWeek > 4 })
}
}
+40
View File
@@ -0,0 +1,40 @@
import XCTest
/// The per-week schedule sheet has to be reachable from the home and show the
/// premium upsell to free users neither is visible to a unit test.
final class WeekScheduleUITests: XCTestCase {
func testWeekScheduleSheetOpensFromHome() 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")
}
let scheduleButton = app.buttons["This week only"]
XCTAssertTrue(scheduleButton.waitForExistence(timeout: 10), "Week schedule button not found on the home")
scheduleButton.tap()
XCTAssertTrue(app.navigationBars["This week only"].waitForExistence(timeout: 5), "Week schedule sheet did not open")
XCTAssertTrue(app.switches["Breakfast"].waitForExistence(timeout: 5), "Meal toggles missing")
XCTAssertTrue(app.switches["Include weekends"].exists, "Weekend toggle missing")
// Free install: the sheet sells premium instead of applying changes.
XCTAssertTrue(app.staticTexts["Customising a single week is Premium"].exists, "Premium upsell missing for a free user")
XCTAssertFalse(app.buttons["Apply"].isEnabled, "Apply must stay disabled without premium")
let screenshot = app.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = "week-schedule-sheet"
attachment.lifetime = .keepAlways
add(attachment)
try? screenshot.pngRepresentation.write(to: URL(fileURLWithPath: "/tmp/mealmood-week-schedule-sheet.png"))
}
}