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:
@@ -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 = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
@@ -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 = "<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>"; };
|
||||
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>"; };
|
||||
@@ -242,6 +248,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -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 = "<group>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
@@ -372,6 +380,7 @@
|
||||
69B309A43E486B5A723324BA /* WeekCalendarView.swift */,
|
||||
2E113A29DEC0551C41AB7EBB /* WeekPlanShareView.swift */,
|
||||
1975C49FFC7D91AEB7932C97 /* SocialShareSheet.swift */,
|
||||
C4A46F6218891B47163E5DDA /* WeekScheduleSheet.swift */,
|
||||
);
|
||||
path = Home;
|
||||
sourceTree = "<group>";
|
||||
@@ -459,6 +468,7 @@
|
||||
049AE68D5B577514ADD6BACB /* Tag.swift */,
|
||||
A23267BAA116E1214870416C /* WeekPlan.swift */,
|
||||
3FC93C653B5540D7205BF294 /* ShoppingItem.swift */,
|
||||
27337A07289450A1D494AC02 /* WeekSchedule.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
@@ -496,6 +506,7 @@
|
||||
17B976BF8B0BD6D1AA5F8C68 /* TagLocalizationTests.swift */,
|
||||
62E2F6B2694CEC97AE3C1B13 /* PremiumSyncServiceTests.swift */,
|
||||
69C0BDFEBA8B692530CB6B5E /* SpeechDictationServiceTests.swift */,
|
||||
91C2BCF9D7A1458E045DA053 /* WeekScheduleTests.swift */,
|
||||
);
|
||||
path = MealMoodTests;
|
||||
sourceTree = "<group>";
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
"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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]] = [:]
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<Int> {
|
||||
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 {
|
||||
|
||||
@@ -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<Int> {
|
||||
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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user