From 66128e7a5ce1e6779437b1367234c6701e2c44dc Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Sat, 12 Sep 2026 12:05:34 +0200 Subject: [PATCH] =?UTF-8?q?premium:=20configurar=20d=C3=ADas=20y=20comidas?= =?UTF-8?q?=20de=20una=20semana=20concreta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246 --- MealMood.xcodeproj/project.pbxproj | 16 ++ MealMood/Models/WeekPlan.swift | 37 +++ MealMood/Models/WeekSchedule.swift | 38 +++ .../Resources/de.lproj/Localizable.strings | 16 ++ .../Resources/en.lproj/Localizable.strings | 16 ++ .../Resources/es.lproj/Localizable.strings | 16 ++ .../Resources/fr.lproj/Localizable.strings | 16 ++ .../Resources/it.lproj/Localizable.strings | 16 ++ .../Resources/pt-BR.lproj/Localizable.strings | 16 ++ MealMood/Services/DefaultDataService.swift | 6 +- MealMood/Services/ICloudSyncService.swift | 8 + MealMood/Services/PremiumAccess.swift | 5 + MealMood/Services/WatchSyncService.swift | 6 +- MealMood/Services/WidgetDataStore.swift | 2 +- MealMood/ViewModels/HomeViewModel.swift | 52 +++- MealMood/Views/Home/HomeView.swift | 57 ++++- MealMood/Views/Home/WeekCalendarView.swift | 14 +- MealMood/Views/Home/WeekPlanShareView.swift | 10 +- MealMood/Views/Home/WeekScheduleSheet.swift | 172 +++++++++++++ MealMoodTests/WeekScheduleTests.swift | 235 ++++++++++++++++++ MealMoodUITests/WeekScheduleUITests.swift | 40 +++ 21 files changed, 777 insertions(+), 17 deletions(-) create mode 100644 MealMood/Models/WeekSchedule.swift create mode 100644 MealMood/Views/Home/WeekScheduleSheet.swift create mode 100644 MealMoodTests/WeekScheduleTests.swift create mode 100644 MealMoodUITests/WeekScheduleUITests.swift diff --git a/MealMood.xcodeproj/project.pbxproj b/MealMood.xcodeproj/project.pbxproj index 01df2c9..e49a874 100644 --- a/MealMood.xcodeproj/project.pbxproj +++ b/MealMood.xcodeproj/project.pbxproj @@ -35,6 +35,7 @@ 511DDB82FF4DD30018101418 /* DishDrawerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23957C735075B0DFB1DF51D7 /* DishDrawerView.swift */; }; 519AA84E7D3B9ED5FC215E4B /* MealMoodWatchWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A43C4A6B38F731B9FE6AACB /* MealMoodWatchWidget.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 */; }; 5BCED5AFADACD4118AE59AF9 /* Font+MealMood.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE73D5B7AF8B5E2D63105977 /* Font+MealMood.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 */; }; 867A511F154A33E9DD7FE9BC /* CalendarService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A631C6D0CF7A0724ACF6A5 /* CalendarService.swift */; }; 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 */; }; 8CEAC8993D33BFBBEB5319A8 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EE10285FEE9BE0684CC66A3 /* NotificationService.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 */; }; A1B2C3D4E5F60718293A4B03 /* SpeechDictationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B04 /* SpeechDictationService.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 */; }; A7CAD588CF78F404C8065CF1 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EDEE086FD20D16DE507B247 /* HomeViewModel.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 */; }; C7B1F5A21B5E4A9E91B7F5A2 /* BottomPromoBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E2F6A3F47E5C9F92A8E6B3 /* BottomPromoBanner.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 */; }; CD2ADCDADFEC2D562E23D8E6 /* AutocompleteEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7AB2ECCA7D330E50AC549E /* AutocompleteEngine.swift */; }; 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 = ""; }; 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 = ""; }; 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; }; @@ -231,6 +236,7 @@ 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 = ""; }; 6EA1948B5DAEEEA09B78C8A9 /* SecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryButton.swift; sourceTree = ""; }; + 6FCDE0D7D1609EC0DB3DD134 /* WeekScheduleUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WeekScheduleUITests.swift; sourceTree = ""; }; 70D140A6E835CA6227C2D4D9 /* WelcomeStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeStepView.swift; sourceTree = ""; }; 71F0ADA350289AE410D572A7 /* IngredientGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IngredientGenerator.swift; sourceTree = ""; }; 7603187713D390BD4D35770C /* AutocompleteEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteEngineTests.swift; sourceTree = ""; }; @@ -242,6 +248,7 @@ 89C47DD7B8F4B1441780E611 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; 8CFA001E1BFA916ABC4C44C1 /* PremiumUpsellBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumUpsellBanner.swift; sourceTree = ""; }; 8EE10285FEE9BE0684CC66A3 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + 91C2BCF9D7A1458E045DA053 /* WeekScheduleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WeekScheduleTests.swift; sourceTree = ""; }; 928E6D4F6D78AD1840245FB4 /* PremiumSyncService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PremiumSyncService.swift; sourceTree = ""; }; 93B4B31A8F6E4D7AAB1B6A01 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; 965555A7774FF7EB975F0A07 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; @@ -266,6 +273,7 @@ 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 = ""; }; 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 = ""; }; 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; }; C7C00EED19123F00AE87FF24 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; @@ -372,6 +380,7 @@ 69B309A43E486B5A723324BA /* WeekCalendarView.swift */, 2E113A29DEC0551C41AB7EBB /* WeekPlanShareView.swift */, 1975C49FFC7D91AEB7932C97 /* SocialShareSheet.swift */, + C4A46F6218891B47163E5DDA /* WeekScheduleSheet.swift */, ); path = Home; sourceTree = ""; @@ -459,6 +468,7 @@ 049AE68D5B577514ADD6BACB /* Tag.swift */, A23267BAA116E1214870416C /* WeekPlan.swift */, 3FC93C653B5540D7205BF294 /* ShoppingItem.swift */, + 27337A07289450A1D494AC02 /* WeekSchedule.swift */, ); path = Models; sourceTree = ""; @@ -496,6 +506,7 @@ 17B976BF8B0BD6D1AA5F8C68 /* TagLocalizationTests.swift */, 62E2F6B2694CEC97AE3C1B13 /* PremiumSyncServiceTests.swift */, 69C0BDFEBA8B692530CB6B5E /* SpeechDictationServiceTests.swift */, + 91C2BCF9D7A1458E045DA053 /* WeekScheduleTests.swift */, ); path = MealMoodTests; sourceTree = ""; @@ -584,6 +595,7 @@ isa = PBXGroup; children = ( 3CBD59AC26D5C6D138D67E35 /* OnboardingFlowUITests.swift */, + 6FCDE0D7D1609EC0DB3DD134 /* WeekScheduleUITests.swift */, ); name = MealMoodUITests; path = MealMoodUITests; @@ -917,6 +929,7 @@ buildActionMask = 2147483647; files = ( 8DB624B469178CFD8CD87672 /* OnboardingFlowUITests.swift in Sources */, + CC96D3416E044B750C99B625 /* WeekScheduleUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -959,6 +972,7 @@ ACAEFA5F4C170C6438CD2652 /* TagLocalizationTests.swift in Sources */, 2A58E7C96AF7C14EBFE1C65C /* PremiumSyncServiceTests.swift in Sources */, 64CC61B20F0AF602A6CC1114 /* SpeechDictationServiceTests.swift in Sources */, + A569F5FED382E8EABC471416 /* WeekScheduleTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1039,6 +1053,8 @@ 6685C591F837FAFB29A322F7 /* DeduplicationService.swift in Sources */, BF89CD5E8CA0DFFC224CF94D /* WatchWeekPayload.swift in Sources */, C6DBE6B5B4BE002A234CBCCA /* WatchSyncService.swift in Sources */, + 8B12FAE3CC07AFB284094C61 /* WeekSchedule.swift in Sources */, + 57F913037D2D381DFA1FB7DE /* WeekScheduleSheet.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/MealMood/Models/WeekPlan.swift b/MealMood/Models/WeekPlan.swift index 2f00b1d..26fbdf0 100644 --- a/MealMood/Models/WeekPlan.swift +++ b/MealMood/Models/WeekPlan.swift @@ -12,6 +12,13 @@ final class WeekPlan { var updatedAt: Date = Date() 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) var slots: [MealSlot]? = [] @@ -21,6 +28,36 @@ final class WeekPlan { 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( id: UUID = UUID(), weekStartDate: Date, diff --git a/MealMood/Models/WeekSchedule.swift b/MealMood/Models/WeekSchedule.swift new file mode 100644 index 0000000..47f66e1 --- /dev/null +++ b/MealMood/Models/WeekSchedule.swift @@ -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 { 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 + ) + } +} diff --git a/MealMood/Resources/de.lproj/Localizable.strings b/MealMood/Resources/de.lproj/Localizable.strings index 47e52a9..84bc7ff 100644 --- a/MealMood/Resources/de.lproj/Localizable.strings +++ b/MealMood/Resources/de.lproj/Localizable.strings @@ -462,3 +462,19 @@ "stats_dishes_per_tag_title" = "Gerichte pro Tag"; "violations_banner_title" = "%d Mahlzeit(en) verletzen deine Regeln"; "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"; diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index 0ddd1bb..2c18cbf 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -462,3 +462,19 @@ "stats_dishes_per_tag_title" = "Dishes per tag"; "violations_banner_title" = "%d meal(s) break your rules"; "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"; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index 2e904ac..1542b1e 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -462,3 +462,19 @@ "stats_dishes_per_tag_title" = "Platos por etiqueta"; "violations_banner_title" = "%d comida(s) incumplen tus reglas"; "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"; diff --git a/MealMood/Resources/fr.lproj/Localizable.strings b/MealMood/Resources/fr.lproj/Localizable.strings index f30b83d..4c1662d 100644 --- a/MealMood/Resources/fr.lproj/Localizable.strings +++ b/MealMood/Resources/fr.lproj/Localizable.strings @@ -462,3 +462,19 @@ "stats_dishes_per_tag_title" = "Plats par étiquette"; "violations_banner_title" = "%d repas ne respecte(nt) pas vos règles"; "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"; diff --git a/MealMood/Resources/it.lproj/Localizable.strings b/MealMood/Resources/it.lproj/Localizable.strings index 9008f23..e3da5e0 100644 --- a/MealMood/Resources/it.lproj/Localizable.strings +++ b/MealMood/Resources/it.lproj/Localizable.strings @@ -462,3 +462,19 @@ "stats_dishes_per_tag_title" = "Piatti per etichetta"; "violations_banner_title" = "%d pasto/i non rispettano le tue regole"; "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"; diff --git a/MealMood/Resources/pt-BR.lproj/Localizable.strings b/MealMood/Resources/pt-BR.lproj/Localizable.strings index c220807..eb568a2 100644 --- a/MealMood/Resources/pt-BR.lproj/Localizable.strings +++ b/MealMood/Resources/pt-BR.lproj/Localizable.strings @@ -462,3 +462,19 @@ "stats_dishes_per_tag_title" = "Pratos por etiqueta"; "violations_banner_title" = "%d refeição(ões) quebram suas regras"; "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"; diff --git a/MealMood/Services/DefaultDataService.swift b/MealMood/Services/DefaultDataService.swift index 2558ccc..a88eb0a 100644 --- a/MealMood/Services/DefaultDataService.swift +++ b/MealMood/Services/DefaultDataService.swift @@ -47,8 +47,10 @@ struct DefaultDataService { let plan = WeekPlan(weekStartDate: weekStart) context.insert(plan) - let maxDay = settings.includeWeekends ? 6 : 4 - let mealTypes: [String] = settings.activeMealTypes.map(\.rawValue) + // A brand-new plan has no override yet, so this is the global schedule. + let schedule = settings.schedule(for: plan) + let maxDay = schedule.lastDay + let mealTypes: [String] = schedule.mealTypes.map(\.rawValue) for day in 0...maxDay { for mealType in mealTypes { diff --git a/MealMood/Services/ICloudSyncService.swift b/MealMood/Services/ICloudSyncService.swift index f5bb940..d9b1500 100644 --- a/MealMood/Services/ICloudSyncService.swift +++ b/MealMood/Services/ICloudSyncService.swift @@ -109,6 +109,8 @@ final class ICloudSyncService { weekStartDate: plan.weekStartDate, createdAt: plan.createdAt, updatedAt: plan.updatedAt, + includeWeekendsOverride: plan.includeWeekendsOverride, + mealTypesOverrideRaw: plan.mealTypesOverrideRaw, slots: plan.slotList.map { MealSlotPayload( id: $0.id, @@ -206,6 +208,8 @@ final class ICloudSyncService { createdAt: payload.createdAt, updatedAt: payload.updatedAt ) + plan.includeWeekendsOverride = payload.includeWeekendsOverride + plan.mealTypesOverrideRaw = payload.mealTypesOverrideRaw context.insert(plan) payload.slots.forEach { slotPayload in @@ -289,6 +293,10 @@ private struct WeekPlanPayload: Codable { let weekStartDate: Date let createdAt: 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] } diff --git a/MealMood/Services/PremiumAccess.swift b/MealMood/Services/PremiumAccess.swift index 928c36b..70092ec 100644 --- a/MealMood/Services/PremiumAccess.swift +++ b/MealMood/Services/PremiumAccess.swift @@ -13,6 +13,11 @@ enum PremiumAccess { !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 { isPremium || UserDefaults.standard.integer(forKey: generationCountKey) < freeIngredientGenerations } diff --git a/MealMood/Services/WatchSyncService.swift b/MealMood/Services/WatchSyncService.swift index 11c01af..a97ca54 100644 --- a/MealMood/Services/WatchSyncService.swift +++ b/MealMood/Services/WatchSyncService.swift @@ -52,10 +52,10 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable { let todayOffset = (Calendar.current.component(.weekday, from: Date()) + 5) % 7 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 meals: [WatchWeekPayload.Meal] = settings.activeMealTypes.map { meal in + let days: [WatchWeekPayload.Day] = schedule.dayRange.map { day in + let meals: [WatchWeekPayload.Meal] = schedule.mealTypes.map { meal in let slot = plan.slotList.first { $0.dayOfWeek == day && $0.mealType == meal.rawValue } let label = String(localized: String.LocalizationValue(meal.localizedKey)) var name: String? diff --git a/MealMood/Services/WidgetDataStore.swift b/MealMood/Services/WidgetDataStore.swift index 9221e42..6b70a69 100644 --- a/MealMood/Services/WidgetDataStore.swift +++ b/MealMood/Services/WidgetDataStore.swift @@ -25,7 +25,7 @@ enum WidgetDataStore { // weekday: 1=Sun,2=Mon..7=Sat → appDayOfWeek: 0=Mon..6=Sun 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 { $0.dayOfWeek == appDayOfWeek && $0.mealType == mealType.rawValue } diff --git a/MealMood/ViewModels/HomeViewModel.swift b/MealMood/ViewModels/HomeViewModel.swift index ad6de65..c04850b 100644 --- a/MealMood/ViewModels/HomeViewModel.swift +++ b/MealMood/ViewModels/HomeViewModel.swift @@ -555,14 +555,62 @@ final class HomeViewModel: ObservableObject { 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 { let dayOfWeek: Int let mealType: String } private func syncSlotsIfNeeded(plan: WeekPlan, settings: AppSettings, context: ModelContext) { - let maxDay = settings.includeWeekends ? 6 : 4 - let mealTypes: [String] = settings.activeMealTypes.map(\.rawValue) + let schedule = settings.schedule(for: plan) + 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. var groupedByKey: [SlotKey: [MealSlot]] = [:] diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index abb300e..9aef02a 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -24,6 +24,7 @@ struct HomeView: View { @State private var selectedEmptySlotId: UUID? @State private var selectedFilledSlotId: UUID? @State private var showWeekPicker: Bool = false + @State private var showWeekSchedule: Bool = false @State private var weekPickerDate: Date = Date() @State private var showCopyPreviousConfirm: Bool = false @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, /// where a premium user's four leading buttons squeezed it until the arrows /// 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) { Button { viewModel.goToPreviousWeek() @@ -311,6 +312,34 @@ struct HomeView: View { } .buttonStyle(.plain) .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(.top, 6) @@ -380,7 +409,7 @@ struct HomeView: View { @ViewBuilder private func mainContent(plan: WeekPlan, settings: AppSettings) -> some View { VStack(spacing: 0) { - weekSwitcher(settings: settings) + weekSwitcher(plan: plan, settings: settings) violationsBanner(plan: plan) @@ -577,6 +606,30 @@ struct HomeView: View { } .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( isPresented: Binding( get: { selectedEmptySlotId != nil }, diff --git a/MealMood/Views/Home/WeekCalendarView.swift b/MealMood/Views/Home/WeekCalendarView.swift index b29b2c8..a018a3b 100644 --- a/MealMood/Views/Home/WeekCalendarView.swift +++ b/MealMood/Views/Home/WeekCalendarView.swift @@ -13,8 +13,12 @@ struct WeekCalendarView: View { var onTapEmptySlot: ((MealSlot) -> Void)? var onTapFilledSlot: ((MealSlot) -> Void)? + private var schedule: WeekSchedule { + settings.schedule(for: plan) + } + private var dayRange: ClosedRange { - settings.includeWeekends ? 0...6 : 0...4 + schedule.dayRange } private var days: [Int] { @@ -22,7 +26,7 @@ struct WeekCalendarView: View { } private var mealTypes: [MealType] { - settings.activeMealTypes + schedule.mealTypes } var body: some View { @@ -50,6 +54,12 @@ struct WeekCalendarView: View { .onChange(of: settings.mealWindows) { _, _ in refreshDisplaySlots() } + .onChange(of: plan.includeWeekendsOverride) { _, _ in + refreshDisplaySlots() + } + .onChange(of: plan.mealTypesOverrideRaw) { _, _ in + refreshDisplaySlots() + } } private var compactLayout: some View { diff --git a/MealMood/Views/Home/WeekPlanShareView.swift b/MealMood/Views/Home/WeekPlanShareView.swift index 9c6266b..1fc6f05 100644 --- a/MealMood/Views/Home/WeekPlanShareView.swift +++ b/MealMood/Views/Home/WeekPlanShareView.swift @@ -15,9 +15,9 @@ enum WeekPlanTextExporter { lines.append("🍽️ *\(String(localized: "share_week_title")) · \(plan.weekStartDate.formattedWeekRange())*") lines.append("") - let dayRange = settings.includeWeekends ? 0...6 : 0...4 - for day in dayRange { - let mealLines = settings.activeMealTypes.compactMap { meal -> String? in + let schedule = settings.schedule(for: plan) + for day in schedule.dayRange { + 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 } let label = String(localized: String.LocalizationValue(meal.localizedKey)) if slot.isEatingOut { @@ -96,11 +96,11 @@ struct WeekPlanShareView: View { } private var dayRange: ClosedRange { - settings.includeWeekends ? 0...6 : 0...4 + settings.schedule(for: plan).dayRange } private var mealTypes: [MealType] { - settings.activeMealTypes + settings.schedule(for: plan).mealTypes } private var exportStyle: WeekExportStyle { diff --git a/MealMood/Views/Home/WeekScheduleSheet.swift b/MealMood/Views/Home/WeekScheduleSheet.swift new file mode 100644 index 0000000..d53b2ec --- /dev/null +++ b/MealMood/Views/Home/WeekScheduleSheet.swift @@ -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 + @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() + } +} diff --git a/MealMoodTests/WeekScheduleTests.swift b/MealMoodTests/WeekScheduleTests.swift new file mode 100644 index 0000000..239769f --- /dev/null +++ b/MealMoodTests/WeekScheduleTests.swift @@ -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 }) + } +} diff --git a/MealMoodUITests/WeekScheduleUITests.swift b/MealMoodUITests/WeekScheduleUITests.swift new file mode 100644 index 0000000..c9b6178 --- /dev/null +++ b/MealMoodUITests/WeekScheduleUITests.swift @@ -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")) + } +}