Version casi lista
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"appPolicies" : {
|
||||
"eula" : "",
|
||||
"policies" : [
|
||||
{
|
||||
"locale" : "en_US",
|
||||
"policyText" : "",
|
||||
"policyURL" : ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"identifier" : "0502129E",
|
||||
"nonRenewingSubscriptions" : [
|
||||
|
||||
],
|
||||
"products" : [
|
||||
|
||||
],
|
||||
"settings" : {
|
||||
"_applicationInternalID" : "6759255553",
|
||||
"_askToBuyEnabled" : false,
|
||||
"_billingGracePeriodEnabled" : false,
|
||||
"_billingIssuesEnabled" : false,
|
||||
"_developerTeamID" : "2825Q76T7H",
|
||||
"_disableDialogs" : false,
|
||||
"_failTransactionsEnabled" : false,
|
||||
"_locale" : "en_US",
|
||||
"_renewalBillingIssuesEnabled" : false,
|
||||
"_shouldSetupSyncOnLoad" : true,
|
||||
"_storefront" : "USA",
|
||||
"_storeKitErrors" : [
|
||||
|
||||
],
|
||||
"_timeRate" : 0
|
||||
},
|
||||
"subscriptionGroups" : [
|
||||
|
||||
],
|
||||
"version" : {
|
||||
"major" : 4,
|
||||
"minor" : 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,815 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 63;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
016D78C0DC1B1F6E671F36F7 /* OnboardingViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 168A6CA95246E1BB72797D4E /* OnboardingViewModelTests.swift */; };
|
||||
0F91C8611D3BBE1FBD51D1E6 /* MealSlot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797A41D292142240FBDF9F2A /* MealSlot.swift */; };
|
||||
19330C6ECE7A60110A3A679D /* TagListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB00DA865F6FE86952BFC3 /* TagListView.swift */; };
|
||||
203DDFF14C0688DEDB6EE83E /* Localization+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0164A33496A304E40685EA6D /* Localization+Helpers.swift */; };
|
||||
2519A55C6BDA686B6F85E79D /* View+MealCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC01EE9D78D980727A644CDF /* View+MealCard.swift */; };
|
||||
294D7F9BE5A2907F74B8614C /* Date+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DE70CD2A01FCE5370A46DD3 /* Date+Helpers.swift */; };
|
||||
30540EF74F2C97F741BA6A1A /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049AE68D5B577514ADD6BACB /* Tag.swift */; };
|
||||
31DFC035AE34F49FB35071A7 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C47DD7B8F4B1441780E611 /* HomeView.swift */; };
|
||||
37F5CEC4862C0AE2F3385ABA /* DishListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB3BD0E078644749E93B0D2B /* DishListView.swift */; };
|
||||
3C5225918DA8DE77F5AC1AA0 /* MealMoodApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C20D9FC522FEBF7206CFD32 /* MealMoodApp.swift */; };
|
||||
4218F220B7412965A5001A88 /* CalendarStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76719058DD3A4177B2B4163E /* CalendarStepView.swift */; };
|
||||
48F9D3308375F820B2B88D1B /* PremiumUpsellBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CFA001E1BFA916ABC4C44C1 /* PremiumUpsellBanner.swift */; };
|
||||
4D53B5101AE845D7C03A0EB4 /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = 192E7CF40FD34C3FF4DDFDA3 /* GoogleMobileAds */; };
|
||||
511DDB82FF4DD30018101418 /* DishDrawerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23957C735075B0DFB1DF51D7 /* DishDrawerView.swift */; };
|
||||
5BCED5AFADACD4118AE59AF9 /* Font+MealMood.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE73D5B7AF8B5E2D63105977 /* Font+MealMood.swift */; };
|
||||
5E7322F3F3624211985F2D99 /* ICloudSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED090ADA1FB259AD2AFEDE2 /* ICloudSyncService.swift */; };
|
||||
5ECA2501EC257777DBE670AD /* EmptySlotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68E5C82608F60FF8798BB81 /* EmptySlotView.swift */; };
|
||||
6553473B007AFD04079878C6 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F0A5C2FF1B797A4BB69B39 /* ContentView.swift */; };
|
||||
670A11A9850B321D9FF76B41 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D718B2E4F7E6D22E502F0A6 /* OnboardingView.swift */; };
|
||||
6C808D1B6E8534FABC4A4053 /* WeekPlanShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E113A29DEC0551C41AB7EBB /* WeekPlanShareView.swift */; };
|
||||
717C67C9A7F48587667B5AE2 /* Dish.swift in Sources */ = {isa = PBXBuildFile; fileRef = 623EDDC4CD134883FC9D242D /* Dish.swift */; };
|
||||
71D8EB28A881D95A338F4CEE /* TagRulesEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5CD466A50C339519544347 /* TagRulesEditView.swift */; };
|
||||
73B55D5A7B351BD05709E40C /* AutocompleteEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7603187713D390BD4D35770C /* AutocompleteEngineTests.swift */; };
|
||||
786E0F918F60E93A7EE2AD3E /* FilledSlotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4FD72583C004A5D5C939A3 /* FilledSlotView.swift */; };
|
||||
7A77669DAC7A243BA7C88916 /* OnboardingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD621CE9B44512AE8894D92E /* OnboardingViewModel.swift */; };
|
||||
7B1B992619FFD22A92F0E88B /* ReviewPromptService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8825212ACFA0D3F6C716E94 /* ReviewPromptService.swift */; };
|
||||
7CBE113B28462C6C41C35533 /* MealMoodLogo.svg in Resources */ = {isa = PBXBuildFile; fileRef = 065982B284538632951F1C3A /* MealMoodLogo.svg */; };
|
||||
7E163CDBF0832188EFA31C7D /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F46C32F97EAD52F8F3BC0B7 /* SettingsView.swift */; };
|
||||
861557A13918B99C421251E0 /* PremiumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324D77FEC4C8C371695F27CC /* PremiumView.swift */; };
|
||||
867A511F154A33E9DD7FE9BC /* CalendarService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A631C6D0CF7A0724ACF6A5 /* CalendarService.swift */; };
|
||||
87EB371FD3B6C241AC721C55 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 8E15828872FAEA71A7384099 /* Localizable.strings */; };
|
||||
8CEAC8993D33BFBBEB5319A8 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EE10285FEE9BE0684CC66A3 /* NotificationService.swift */; };
|
||||
8F739A4496501009B8D829B3 /* Color+MealMood.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB771E85924AE086647A5113 /* Color+MealMood.swift */; };
|
||||
9BC60C30918C117608E43699 /* DefaultDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E00AB23342224B3407838B /* DefaultDataService.swift */; };
|
||||
9BE86765B8F0368AD5C86FC4 /* TagViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9BFFE7A4BC6961815D2334 /* TagViewModel.swift */; };
|
||||
9D40600B9AC21F0FF64B77F9 /* StoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 103374A7D91B4F884278DA72 /* StoreManager.swift */; };
|
||||
A6F130EC376B7BC270AD908D /* TagPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E1C12C3A693D7C41E9F80B /* TagPill.swift */; };
|
||||
A7CAD588CF78F404C8065CF1 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EDEE086FD20D16DE507B247 /* HomeViewModel.swift */; };
|
||||
A9278F6559777CB9FA7FB69E /* AdMobConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 812303C94C8C1D2FADF3CB37 /* AdMobConfig.swift */; };
|
||||
AB443E1DC9830D13B3E7B837 /* SecondaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA1948B5DAEEEA09B78C8A9 /* SecondaryButton.swift */; };
|
||||
AB496674DF3CA41ACBBE7CFC /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5187023D83BBC5E8D304CE3A /* SettingsViewModel.swift */; };
|
||||
AB76A63B140922D69F314C37 /* HapticManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20F23BF9788A3217E3D63721 /* HapticManager.swift */; };
|
||||
ACAEFA5F4C170C6438CD2652 /* TagLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B976BF8B0BD6D1AA5F8C68 /* TagLocalizationTests.swift */; };
|
||||
AD5F8FDE30CE4B2934B3C1C1 /* WeekPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = A23267BAA116E1214870416C /* WeekPlan.swift */; };
|
||||
B3C9E5149B70C477EACBDFFB /* AdBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050EA827CA951AB9E847E2CA /* AdBannerView.swift */; };
|
||||
B8D7CA62789429C2F693E4F6 /* MealMoodLogoMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC64110469AA91D5F28C423 /* MealMoodLogoMark.swift */; };
|
||||
BE908B23F64F33D594AB8D82 /* DateHelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD33453B7DF86EDBAEB80F1 /* DateHelpersTests.swift */; };
|
||||
C14BDE4C3EF6AF75F378D892 /* PrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA97AD958C922D8AFF73314 /* PrimaryButton.swift */; };
|
||||
C565A1975302748A2B2152BE /* DishViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8472961AE65E2A3460B67C3 /* DishViewModel.swift */; };
|
||||
C81C935386BE1E939DA55F8D /* MealWindowsStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA11C8A81D5C0CA8A3C13D /* MealWindowsStepView.swift */; };
|
||||
CD2ADCDADFEC2D562E23D8E6 /* AutocompleteEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7AB2ECCA7D330E50AC549E /* AutocompleteEngine.swift */; };
|
||||
D2840F9D92D16026338CE83C /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7C00EED19123F00AE87FF24 /* AppSettings.swift */; };
|
||||
D8149D55E74F7BBFC0402F46 /* DishFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63CA2A8666E1FBBAD65F141 /* DishFormView.swift */; };
|
||||
D89413095E37C3D677856437 /* PremiumAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0200C0C11C912711C724558 /* PremiumAccess.swift */; };
|
||||
DE43F08AAC939C083BF511C1 /* FirstDishesStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10594EA55F6BEFF4241D364C /* FirstDishesStepView.swift */; };
|
||||
DE797C90DFDAFAC5C12E3D97 /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFBCD4FFE0D067A591104B74 /* ToastView.swift */; };
|
||||
E12A5EE3D940260902277CC5 /* WeekCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69B309A43E486B5A723324BA /* WeekCalendarView.swift */; };
|
||||
E2524335ED3A5DB7813BEA99 /* WelcomeStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70D140A6E835CA6227C2D4D9 /* WelcomeStepView.swift */; };
|
||||
E3BD64E6D32ED7052F174923 /* TagSelectorSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CF1C9CC9BDB3B20B1834EB /* TagSelectorSheet.swift */; };
|
||||
E6A57F29C02AE138D705BAF7 /* AppIconPlaceholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00F2EE5A756C183B6D505403 /* AppIconPlaceholder.swift */; };
|
||||
EA4EC6244EEBE6D568BCA5E5 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B3944B4AD876455057EB2265 /* Assets.xcassets */; };
|
||||
F65AD80426AF8DA12FBD661C /* WeekendsStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035C6C0B728EB0EA80394CDD /* WeekendsStepView.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
EFE9F2305F13749C6FCAC8E5 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 7C11D794B7E45A145D5ADBFD /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = DE69ED74719D07B4A5770741;
|
||||
remoteInfo = MealMood;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
00F2EE5A756C183B6D505403 /* AppIconPlaceholder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconPlaceholder.swift; sourceTree = "<group>"; };
|
||||
0164A33496A304E40685EA6D /* Localization+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Localization+Helpers.swift"; sourceTree = "<group>"; };
|
||||
035C6C0B728EB0EA80394CDD /* WeekendsStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekendsStepView.swift; sourceTree = "<group>"; };
|
||||
049AE68D5B577514ADD6BACB /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
|
||||
050EA827CA951AB9E847E2CA /* AdBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdBannerView.swift; sourceTree = "<group>"; };
|
||||
065982B284538632951F1C3A /* MealMoodLogo.svg */ = {isa = PBXFileReference; lastKnownFileType = text; path = MealMoodLogo.svg; sourceTree = "<group>"; };
|
||||
0F46C32F97EAD52F8F3BC0B7 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
103374A7D91B4F884278DA72 /* StoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreManager.swift; sourceTree = "<group>"; };
|
||||
10594EA55F6BEFF4241D364C /* FirstDishesStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstDishesStepView.swift; sourceTree = "<group>"; };
|
||||
13DB00DA865F6FE86952BFC3 /* TagListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagListView.swift; sourceTree = "<group>"; };
|
||||
168A6CA95246E1BB72797D4E /* OnboardingViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewModelTests.swift; sourceTree = "<group>"; };
|
||||
17B976BF8B0BD6D1AA5F8C68 /* TagLocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagLocalizationTests.swift; sourceTree = "<group>"; };
|
||||
17E00AB23342224B3407838B /* DefaultDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultDataService.swift; sourceTree = "<group>"; };
|
||||
20F23BF9788A3217E3D63721 /* HapticManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticManager.swift; sourceTree = "<group>"; };
|
||||
23957C735075B0DFB1DF51D7 /* DishDrawerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DishDrawerView.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>"; };
|
||||
2E113A29DEC0551C41AB7EBB /* WeekPlanShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekPlanShareView.swift; sourceTree = "<group>"; };
|
||||
324D77FEC4C8C371695F27CC /* PremiumView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumView.swift; sourceTree = "<group>"; };
|
||||
4EDEE086FD20D16DE507B247 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
||||
50E1C12C3A693D7C41E9F80B /* TagPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagPill.swift; sourceTree = "<group>"; };
|
||||
5187023D83BBC5E8D304CE3A /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
|
||||
5C9BFFE7A4BC6961815D2334 /* TagViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagViewModel.swift; sourceTree = "<group>"; };
|
||||
60F0A5C2FF1B797A4BB69B39 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
623EDDC4CD134883FC9D242D /* Dish.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dish.swift; sourceTree = "<group>"; };
|
||||
69B309A43E486B5A723324BA /* WeekCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekCalendarView.swift; sourceTree = "<group>"; };
|
||||
6D718B2E4F7E6D22E502F0A6 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||
6EA1948B5DAEEEA09B78C8A9 /* SecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryButton.swift; sourceTree = "<group>"; };
|
||||
70D140A6E835CA6227C2D4D9 /* WelcomeStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeStepView.swift; sourceTree = "<group>"; };
|
||||
7603187713D390BD4D35770C /* AutocompleteEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteEngineTests.swift; sourceTree = "<group>"; };
|
||||
76719058DD3A4177B2B4163E /* CalendarStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarStepView.swift; sourceTree = "<group>"; };
|
||||
797A41D292142240FBDF9F2A /* MealSlot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealSlot.swift; sourceTree = "<group>"; };
|
||||
7DE70CD2A01FCE5370A46DD3 /* Date+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Helpers.swift"; sourceTree = "<group>"; };
|
||||
812303C94C8C1D2FADF3CB37 /* AdMobConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdMobConfig.swift; sourceTree = "<group>"; };
|
||||
8411FF2737FDEF48170F9634 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
89C47DD7B8F4B1441780E611 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
|
||||
8CFA001E1BFA916ABC4C44C1 /* PremiumUpsellBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumUpsellBanner.swift; sourceTree = "<group>"; };
|
||||
8EE10285FEE9BE0684CC66A3 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||
965555A7774FF7EB975F0A07 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
9F86EB5DDB126D73486FD956 /* MealMood.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MealMood.entitlements; sourceTree = "<group>"; };
|
||||
9FBA11C8A81D5C0CA8A3C13D /* MealWindowsStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealWindowsStepView.swift; sourceTree = "<group>"; };
|
||||
A23267BAA116E1214870416C /* WeekPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekPlan.swift; sourceTree = "<group>"; };
|
||||
AB771E85924AE086647A5113 /* Color+MealMood.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+MealMood.swift"; sourceTree = "<group>"; };
|
||||
B0A631C6D0CF7A0724ACF6A5 /* CalendarService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarService.swift; sourceTree = "<group>"; };
|
||||
B3944B4AD876455057EB2265 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
B68E5C82608F60FF8798BB81 /* EmptySlotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptySlotView.swift; sourceTree = "<group>"; };
|
||||
B6CF1C9CC9BDB3B20B1834EB /* TagSelectorSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagSelectorSheet.swift; sourceTree = "<group>"; };
|
||||
BAC64110469AA91D5F28C423 /* MealMoodLogoMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealMoodLogoMark.swift; sourceTree = "<group>"; };
|
||||
BED090ADA1FB259AD2AFEDE2 /* ICloudSyncService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ICloudSyncService.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
CB3BD0E078644749E93B0D2B /* DishListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DishListView.swift; sourceTree = "<group>"; };
|
||||
CE7AB2ECCA7D330E50AC549E /* AutocompleteEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteEngine.swift; sourceTree = "<group>"; };
|
||||
CFBCD4FFE0D067A591104B74 /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = "<group>"; };
|
||||
D63CA2A8666E1FBBAD65F141 /* DishFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DishFormView.swift; sourceTree = "<group>"; };
|
||||
DBD33453B7DF86EDBAEB80F1 /* DateHelpersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateHelpersTests.swift; sourceTree = "<group>"; };
|
||||
DFA97AD958C922D8AFF73314 /* PrimaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryButton.swift; sourceTree = "<group>"; };
|
||||
E8472961AE65E2A3460B67C3 /* DishViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DishViewModel.swift; sourceTree = "<group>"; };
|
||||
E8825212ACFA0D3F6C716E94 /* ReviewPromptService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPromptService.swift; sourceTree = "<group>"; };
|
||||
F0200C0C11C912711C724558 /* PremiumAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumAccess.swift; sourceTree = "<group>"; };
|
||||
F766AA7898D6F391DE1B9A81 /* MealMoodTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = MealMoodTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FC01EE9D78D980727A644CDF /* View+MealCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+MealCard.swift"; sourceTree = "<group>"; };
|
||||
FD4FD72583C004A5D5C939A3 /* FilledSlotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilledSlotView.swift; sourceTree = "<group>"; };
|
||||
FD621CE9B44512AE8894D92E /* OnboardingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewModel.swift; sourceTree = "<group>"; };
|
||||
FE73D5B7AF8B5E2D63105977 /* Font+MealMood.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Font+MealMood.swift"; sourceTree = "<group>"; };
|
||||
FF2DE77AD57DB6DAB188CD9E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
9240A703A486116A9871C534 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4D53B5101AE845D7C03A0EB4 /* GoogleMobileAds in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
011822327F563ED89CB571FB = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8C162D5DCA596B64BEE7AD7C /* MealMood */,
|
||||
7975F99C73940CF3F40F658E /* MealMoodTests */,
|
||||
5BEE00A9E25BDAA086585241 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0C0966502AF8E7F06CC92048 /* Home */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
23957C735075B0DFB1DF51D7 /* DishDrawerView.swift */,
|
||||
89C47DD7B8F4B1441780E611 /* HomeView.swift */,
|
||||
69B309A43E486B5A723324BA /* WeekCalendarView.swift */,
|
||||
2E113A29DEC0551C41AB7EBB /* WeekPlanShareView.swift */,
|
||||
);
|
||||
path = Home;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
35F15C6817E504B1114420CD /* Premium */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
324D77FEC4C8C371695F27CC /* PremiumView.swift */,
|
||||
);
|
||||
path = Premium;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4B4ACE1D0F2968B284A1FA7E /* Configuration */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
path = Configuration;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4B80B7CABE3B737897455A26 /* Settings */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0F46C32F97EAD52F8F3BC0B7 /* SettingsView.swift */,
|
||||
);
|
||||
path = Settings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4C220BFE8B9F1CA030AE6A66 /* Extensions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AB771E85924AE086647A5113 /* Color+MealMood.swift */,
|
||||
7DE70CD2A01FCE5370A46DD3 /* Date+Helpers.swift */,
|
||||
FE73D5B7AF8B5E2D63105977 /* Font+MealMood.swift */,
|
||||
0164A33496A304E40685EA6D /* Localization+Helpers.swift */,
|
||||
FC01EE9D78D980727A644CDF /* View+MealCard.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4ED1758EE5CD0A3EB9D9BCDF /* Brand */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
065982B284538632951F1C3A /* MealMoodLogo.svg */,
|
||||
);
|
||||
path = Brand;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
53F699BBFA2A0DDDA1504E9B /* ViewModels */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E8472961AE65E2A3460B67C3 /* DishViewModel.swift */,
|
||||
4EDEE086FD20D16DE507B247 /* HomeViewModel.swift */,
|
||||
FD621CE9B44512AE8894D92E /* OnboardingViewModel.swift */,
|
||||
5187023D83BBC5E8D304CE3A /* SettingsViewModel.swift */,
|
||||
5C9BFFE7A4BC6961815D2334 /* TagViewModel.swift */,
|
||||
);
|
||||
path = ViewModels;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
56A59DD223BBB14856A48F78 /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C7C00EED19123F00AE87FF24 /* AppSettings.swift */,
|
||||
623EDDC4CD134883FC9D242D /* Dish.swift */,
|
||||
797A41D292142240FBDF9F2A /* MealSlot.swift */,
|
||||
049AE68D5B577514ADD6BACB /* Tag.swift */,
|
||||
A23267BAA116E1214870416C /* WeekPlan.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5BEE00A9E25BDAA086585241 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C7B13F4D4D362E50CA65A0C1 /* MealMood.app */,
|
||||
F766AA7898D6F391DE1B9A81 /* MealMoodTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7975F99C73940CF3F40F658E /* MealMoodTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7603187713D390BD4D35770C /* AutocompleteEngineTests.swift */,
|
||||
DBD33453B7DF86EDBAEB80F1 /* DateHelpersTests.swift */,
|
||||
168A6CA95246E1BB72797D4E /* OnboardingViewModelTests.swift */,
|
||||
17B976BF8B0BD6D1AA5F8C68 /* TagLocalizationTests.swift */,
|
||||
);
|
||||
path = MealMoodTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
86967708171329E855F4A8AC /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8BE925662F55707DF083677A /* Dishes */,
|
||||
0C0966502AF8E7F06CC92048 /* Home */,
|
||||
E452C59D71AAD6175947503D /* Onboarding */,
|
||||
35F15C6817E504B1114420CD /* Premium */,
|
||||
4B80B7CABE3B737897455A26 /* Settings */,
|
||||
CE41931687CCC60F76A2B6D9 /* Tags */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8BE925662F55707DF083677A /* Dishes */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D63CA2A8666E1FBBAD65F141 /* DishFormView.swift */,
|
||||
CB3BD0E078644749E93B0D2B /* DishListView.swift */,
|
||||
B6CF1C9CC9BDB3B20B1834EB /* TagSelectorSheet.swift */,
|
||||
);
|
||||
path = Dishes;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8C162D5DCA596B64BEE7AD7C /* MealMood */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
60F0A5C2FF1B797A4BB69B39 /* ContentView.swift */,
|
||||
2C20D9FC522FEBF7206CFD32 /* MealMoodApp.swift */,
|
||||
E83E021071AFB650200BED5B /* Components */,
|
||||
4B4ACE1D0F2968B284A1FA7E /* Configuration */,
|
||||
4C220BFE8B9F1CA030AE6A66 /* Extensions */,
|
||||
56A59DD223BBB14856A48F78 /* Models */,
|
||||
9E695EAC119C9913E6A36CC8 /* Resources */,
|
||||
F9F4F35A66E1D459F23E1679 /* Services */,
|
||||
53F699BBFA2A0DDDA1504E9B /* ViewModels */,
|
||||
86967708171329E855F4A8AC /* Views */,
|
||||
);
|
||||
path = MealMood;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9E695EAC119C9913E6A36CC8 /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B3944B4AD876455057EB2265 /* Assets.xcassets */,
|
||||
8411FF2737FDEF48170F9634 /* Info.plist */,
|
||||
8E15828872FAEA71A7384099 /* Localizable.strings */,
|
||||
9F86EB5DDB126D73486FD956 /* MealMood.entitlements */,
|
||||
4ED1758EE5CD0A3EB9D9BCDF /* Brand */,
|
||||
);
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CE41931687CCC60F76A2B6D9 /* Tags */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
13DB00DA865F6FE86952BFC3 /* TagListView.swift */,
|
||||
2C5CD466A50C339519544347 /* TagRulesEditView.swift */,
|
||||
);
|
||||
path = Tags;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E452C59D71AAD6175947503D /* Onboarding */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
76719058DD3A4177B2B4163E /* CalendarStepView.swift */,
|
||||
10594EA55F6BEFF4241D364C /* FirstDishesStepView.swift */,
|
||||
9FBA11C8A81D5C0CA8A3C13D /* MealWindowsStepView.swift */,
|
||||
6D718B2E4F7E6D22E502F0A6 /* OnboardingView.swift */,
|
||||
035C6C0B728EB0EA80394CDD /* WeekendsStepView.swift */,
|
||||
70D140A6E835CA6227C2D4D9 /* WelcomeStepView.swift */,
|
||||
);
|
||||
path = Onboarding;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E83E021071AFB650200BED5B /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
050EA827CA951AB9E847E2CA /* AdBannerView.swift */,
|
||||
00F2EE5A756C183B6D505403 /* AppIconPlaceholder.swift */,
|
||||
B68E5C82608F60FF8798BB81 /* EmptySlotView.swift */,
|
||||
FD4FD72583C004A5D5C939A3 /* FilledSlotView.swift */,
|
||||
BAC64110469AA91D5F28C423 /* MealMoodLogoMark.swift */,
|
||||
8CFA001E1BFA916ABC4C44C1 /* PremiumUpsellBanner.swift */,
|
||||
DFA97AD958C922D8AFF73314 /* PrimaryButton.swift */,
|
||||
6EA1948B5DAEEEA09B78C8A9 /* SecondaryButton.swift */,
|
||||
50E1C12C3A693D7C41E9F80B /* TagPill.swift */,
|
||||
CFBCD4FFE0D067A591104B74 /* ToastView.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F9F4F35A66E1D459F23E1679 /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
812303C94C8C1D2FADF3CB37 /* AdMobConfig.swift */,
|
||||
CE7AB2ECCA7D330E50AC549E /* AutocompleteEngine.swift */,
|
||||
B0A631C6D0CF7A0724ACF6A5 /* CalendarService.swift */,
|
||||
17E00AB23342224B3407838B /* DefaultDataService.swift */,
|
||||
20F23BF9788A3217E3D63721 /* HapticManager.swift */,
|
||||
BED090ADA1FB259AD2AFEDE2 /* ICloudSyncService.swift */,
|
||||
8EE10285FEE9BE0684CC66A3 /* NotificationService.swift */,
|
||||
F0200C0C11C912711C724558 /* PremiumAccess.swift */,
|
||||
E8825212ACFA0D3F6C716E94 /* ReviewPromptService.swift */,
|
||||
103374A7D91B4F884278DA72 /* StoreManager.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
71CBF84DA3DD6F325A40C1A1 /* MealMoodTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 9E9B4B2D60B7D7F8BF816CBA /* Build configuration list for PBXNativeTarget "MealMoodTests" */;
|
||||
buildPhases = (
|
||||
B1ACBA2CBB77DB5CAD57EB57 /* Sources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
C4E8F43173BDFCAE4D6DFF49 /* PBXTargetDependency */,
|
||||
);
|
||||
name = MealMoodTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = MealMoodTests;
|
||||
productReference = F766AA7898D6F391DE1B9A81 /* MealMoodTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
DE69ED74719D07B4A5770741 /* MealMood */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = BE85686E0B47779700A19EA0 /* Build configuration list for PBXNativeTarget "MealMood" */;
|
||||
buildPhases = (
|
||||
B2A7B3CE9D5681C52D906D1A /* Sources */,
|
||||
60AC9519630B6008787A8859 /* Resources */,
|
||||
9240A703A486116A9871C534 /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = MealMood;
|
||||
packageProductDependencies = (
|
||||
192E7CF40FD34C3FF4DDFDA3 /* GoogleMobileAds */,
|
||||
);
|
||||
productName = MealMood;
|
||||
productReference = C7B13F4D4D362E50CA65A0C1 /* MealMood.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
7C11D794B7E45A145D5ADBFD /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1430;
|
||||
};
|
||||
buildConfigurationList = 82E16632C9B76AC33DE64CBB /* Build configuration list for PBXProject "MealMood" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
Base,
|
||||
en,
|
||||
es,
|
||||
);
|
||||
mainGroup = 011822327F563ED89CB571FB;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
BE33D996A0D3FB7D5E000A7F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */,
|
||||
);
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
DE69ED74719D07B4A5770741 /* MealMood */,
|
||||
71CBF84DA3DD6F325A40C1A1 /* MealMoodTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
60AC9519630B6008787A8859 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
EA4EC6244EEBE6D568BCA5E5 /* Assets.xcassets in Resources */,
|
||||
87EB371FD3B6C241AC721C55 /* Localizable.strings in Resources */,
|
||||
7CBE113B28462C6C41C35533 /* MealMoodLogo.svg in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
B1ACBA2CBB77DB5CAD57EB57 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
73B55D5A7B351BD05709E40C /* AutocompleteEngineTests.swift in Sources */,
|
||||
BE908B23F64F33D594AB8D82 /* DateHelpersTests.swift in Sources */,
|
||||
016D78C0DC1B1F6E671F36F7 /* OnboardingViewModelTests.swift in Sources */,
|
||||
ACAEFA5F4C170C6438CD2652 /* TagLocalizationTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B2A7B3CE9D5681C52D906D1A /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B3C9E5149B70C477EACBDFFB /* AdBannerView.swift in Sources */,
|
||||
A9278F6559777CB9FA7FB69E /* AdMobConfig.swift in Sources */,
|
||||
E6A57F29C02AE138D705BAF7 /* AppIconPlaceholder.swift in Sources */,
|
||||
D2840F9D92D16026338CE83C /* AppSettings.swift in Sources */,
|
||||
CD2ADCDADFEC2D562E23D8E6 /* AutocompleteEngine.swift in Sources */,
|
||||
867A511F154A33E9DD7FE9BC /* CalendarService.swift in Sources */,
|
||||
4218F220B7412965A5001A88 /* CalendarStepView.swift in Sources */,
|
||||
8F739A4496501009B8D829B3 /* Color+MealMood.swift in Sources */,
|
||||
6553473B007AFD04079878C6 /* ContentView.swift in Sources */,
|
||||
294D7F9BE5A2907F74B8614C /* Date+Helpers.swift in Sources */,
|
||||
9BC60C30918C117608E43699 /* DefaultDataService.swift in Sources */,
|
||||
717C67C9A7F48587667B5AE2 /* Dish.swift in Sources */,
|
||||
511DDB82FF4DD30018101418 /* DishDrawerView.swift in Sources */,
|
||||
D8149D55E74F7BBFC0402F46 /* DishFormView.swift in Sources */,
|
||||
37F5CEC4862C0AE2F3385ABA /* DishListView.swift in Sources */,
|
||||
C565A1975302748A2B2152BE /* DishViewModel.swift in Sources */,
|
||||
5ECA2501EC257777DBE670AD /* EmptySlotView.swift in Sources */,
|
||||
786E0F918F60E93A7EE2AD3E /* FilledSlotView.swift in Sources */,
|
||||
DE43F08AAC939C083BF511C1 /* FirstDishesStepView.swift in Sources */,
|
||||
5BCED5AFADACD4118AE59AF9 /* Font+MealMood.swift in Sources */,
|
||||
AB76A63B140922D69F314C37 /* HapticManager.swift in Sources */,
|
||||
31DFC035AE34F49FB35071A7 /* HomeView.swift in Sources */,
|
||||
A7CAD588CF78F404C8065CF1 /* HomeViewModel.swift in Sources */,
|
||||
5E7322F3F3624211985F2D99 /* ICloudSyncService.swift in Sources */,
|
||||
203DDFF14C0688DEDB6EE83E /* Localization+Helpers.swift in Sources */,
|
||||
3C5225918DA8DE77F5AC1AA0 /* MealMoodApp.swift in Sources */,
|
||||
B8D7CA62789429C2F693E4F6 /* MealMoodLogoMark.swift in Sources */,
|
||||
0F91C8611D3BBE1FBD51D1E6 /* MealSlot.swift in Sources */,
|
||||
C81C935386BE1E939DA55F8D /* MealWindowsStepView.swift in Sources */,
|
||||
8CEAC8993D33BFBBEB5319A8 /* NotificationService.swift in Sources */,
|
||||
670A11A9850B321D9FF76B41 /* OnboardingView.swift in Sources */,
|
||||
7A77669DAC7A243BA7C88916 /* OnboardingViewModel.swift in Sources */,
|
||||
D89413095E37C3D677856437 /* PremiumAccess.swift in Sources */,
|
||||
48F9D3308375F820B2B88D1B /* PremiumUpsellBanner.swift in Sources */,
|
||||
861557A13918B99C421251E0 /* PremiumView.swift in Sources */,
|
||||
C14BDE4C3EF6AF75F378D892 /* PrimaryButton.swift in Sources */,
|
||||
7B1B992619FFD22A92F0E88B /* ReviewPromptService.swift in Sources */,
|
||||
AB443E1DC9830D13B3E7B837 /* SecondaryButton.swift in Sources */,
|
||||
7E163CDBF0832188EFA31C7D /* SettingsView.swift in Sources */,
|
||||
AB496674DF3CA41ACBBE7CFC /* SettingsViewModel.swift in Sources */,
|
||||
9D40600B9AC21F0FF64B77F9 /* StoreManager.swift in Sources */,
|
||||
30540EF74F2C97F741BA6A1A /* Tag.swift in Sources */,
|
||||
19330C6ECE7A60110A3A679D /* TagListView.swift in Sources */,
|
||||
A6F130EC376B7BC270AD908D /* TagPill.swift in Sources */,
|
||||
71D8EB28A881D95A338F4CEE /* TagRulesEditView.swift in Sources */,
|
||||
E3BD64E6D32ED7052F174923 /* TagSelectorSheet.swift in Sources */,
|
||||
9BE86765B8F0368AD5C86FC4 /* TagViewModel.swift in Sources */,
|
||||
DE797C90DFDAFAC5C12E3D97 /* ToastView.swift in Sources */,
|
||||
2519A55C6BDA686B6F85E79D /* View+MealCard.swift in Sources */,
|
||||
E12A5EE3D940260902277CC5 /* WeekCalendarView.swift in Sources */,
|
||||
AD5F8FDE30CE4B2934B3C1C1 /* WeekPlan.swift in Sources */,
|
||||
6C808D1B6E8534FABC4A4053 /* WeekPlanShareView.swift in Sources */,
|
||||
F65AD80426AF8DA12FBD661C /* WeekendsStepView.swift in Sources */,
|
||||
E2524335ED3A5DB7813BEA99 /* WelcomeStepView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
C4E8F43173BDFCAE4D6DFF49 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = DE69ED74719D07B4A5770741 /* MealMood */;
|
||||
targetProxy = EFE9F2305F13749C6FCAC8E5 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
8E15828872FAEA71A7384099 /* Localizable.strings */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
FF2DE77AD57DB6DAB188CD9E /* en */,
|
||||
965555A7774FF7EB975F0A07 /* es */,
|
||||
);
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
1FCA89A600CDAC66078A97A2 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 6.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
A29AC9249B9DE06546506655 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.mealmood.tests;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MealMood.app/MealMood";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
B25BBFBD5E65ABC45B72EE38 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = MealMood/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.mealmood;
|
||||
PRODUCT_NAME = MealMood;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
B6C55A4487CCCEE45AAF80D9 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
"DEBUG=1",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 6.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
E2311296738EDCA9CC08E5F5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.mealmood.tests;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MealMood.app/MealMood";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
E9CAFA13F28C3B0E740A13AC /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = MealMood/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.mealmood;
|
||||
PRODUCT_NAME = MealMood;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
82E16632C9B76AC33DE64CBB /* Build configuration list for PBXProject "MealMood" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
B6C55A4487CCCEE45AAF80D9 /* Debug */,
|
||||
1FCA89A600CDAC66078A97A2 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
9E9B4B2D60B7D7F8BF816CBA /* Build configuration list for PBXNativeTarget "MealMoodTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
E2311296738EDCA9CC08E5F5 /* Debug */,
|
||||
A29AC9249B9DE06546506655 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
BE85686E0B47779700A19EA0 /* Build configuration list for PBXNativeTarget "MealMood" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
E9CAFA13F28C3B0E740A13AC /* Debug */,
|
||||
B25BBFBD5E65ABC45B72EE38 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
BE33D996A0D3FB7D5E000A7F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/googleads/swift-package-manager-google-mobile-ads.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 11.13.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
192E7CF40FD34C3FF4DDFDA3 /* GoogleMobileAds */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = BE33D996A0D3FB7D5E000A7F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */;
|
||||
productName = GoogleMobileAds;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 7C11D794B7E45A145D5ADBFD /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"originHash" : "3b9752ff554d5fdc91a7c01c2322a5d851b0979c36f02eb55188d9ab9e3d7e77",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "swift-package-manager-google-mobile-ads",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/googleads/swift-package-manager-google-mobile-ads.git",
|
||||
"state" : {
|
||||
"revision" : "7778cc1ab037c10dbbe026959e51e616bd961be9",
|
||||
"version" : "11.13.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-package-manager-google-user-messaging-platform",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/googleads/swift-package-manager-google-user-messaging-platform.git",
|
||||
"state" : {
|
||||
"revision" : "708a282840c2171ee63bd93b87afa49fe507d70e",
|
||||
"version" : "2.7.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "DE69ED74719D07B4A5770741"
|
||||
BuildableName = "MealMood.app"
|
||||
BlueprintName = "MealMood"
|
||||
ReferencedContainer = "container:MealMood.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "DE69ED74719D07B4A5770741"
|
||||
BuildableName = "MealMood.app"
|
||||
BlueprintName = "MealMood"
|
||||
ReferencedContainer = "container:MealMood.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "71CBF84DA3DD6F325A40C1A1"
|
||||
BuildableName = "MealMoodTests.xctest"
|
||||
BlueprintName = "MealMoodTests"
|
||||
ReferencedContainer = "container:MealMood.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "DE69ED74719D07B4A5770741"
|
||||
BuildableName = "MealMood.app"
|
||||
BlueprintName = "MealMood"
|
||||
ReferencedContainer = "container:MealMood.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../../MealMood/Configuration/MealMoodV4.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "DE69ED74719D07B4A5770741"
|
||||
BuildableName = "MealMood.app"
|
||||
BlueprintName = "MealMood"
|
||||
ReferencedContainer = "container:MealMood.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "DE69ED74719D07B4A5770741"
|
||||
BuildableName = "MealMood.app"
|
||||
BlueprintName = "MealMood"
|
||||
ReferencedContainer = "container:MealMood.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "DE69ED74719D07B4A5770741"
|
||||
BuildableName = "MealMood.app"
|
||||
BlueprintName = "MealMood"
|
||||
ReferencedContainer = "container:MealMood.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "71CBF84DA3DD6F325A40C1A1"
|
||||
BuildableName = "MealMoodTests.xctest"
|
||||
BlueprintName = "MealMoodTests"
|
||||
ReferencedContainer = "container:MealMood.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "DE69ED74719D07B4A5770741"
|
||||
BuildableName = "MealMood.app"
|
||||
BlueprintName = "MealMood"
|
||||
ReferencedContainer = "container:MealMood.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../MealMood/Configuration/MealMood.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "DE69ED74719D07B4A5770741"
|
||||
BuildableName = "MealMood.app"
|
||||
BlueprintName = "MealMood"
|
||||
ReferencedContainer = "container:MealMood.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>MealMood-IAP.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,151 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
#if canImport(GoogleMobileAds)
|
||||
import GoogleMobileAds
|
||||
|
||||
struct AdBannerView: View {
|
||||
var adUnitId: String = AdMobConfig.resolvedBannerHomeUnitId
|
||||
@State private var state: BannerState = .loading
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(Color.mealMoodTextSecondary.opacity(0.22))
|
||||
.frame(height: 1)
|
||||
|
||||
ZStack {
|
||||
Color.mealMoodSurface
|
||||
|
||||
BannerBridge(adUnitId: adUnitId, state: $state)
|
||||
|
||||
if state != .loaded {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "megaphone")
|
||||
Text(state == .loading ? "ads_loading" : "ads_unavailable")
|
||||
.font(.mealMoodCaption)
|
||||
}
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
}
|
||||
.frame(height: 56)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.mealMoodSurface)
|
||||
}
|
||||
}
|
||||
|
||||
private enum BannerState {
|
||||
case loading
|
||||
case loaded
|
||||
case failed
|
||||
}
|
||||
|
||||
private struct BannerBridge: UIViewRepresentable {
|
||||
let adUnitId: String
|
||||
@Binding var state: BannerState
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(state: $state)
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let container = UIView()
|
||||
container.backgroundColor = UIColor(Color.mealMoodSurface)
|
||||
|
||||
let width = max(UIScreen.main.bounds.width, 320)
|
||||
let adSize = GADCurrentOrientationAnchoredAdaptiveBannerAdSizeWithWidth(width)
|
||||
let banner = GADBannerView(adSize: adSize)
|
||||
banner.adUnitID = adUnitId
|
||||
banner.delegate = context.coordinator
|
||||
banner.rootViewController = rootViewController()
|
||||
banner.translatesAutoresizingMaskIntoConstraints = false
|
||||
banner.backgroundColor = .clear
|
||||
context.coordinator.bannerView = banner
|
||||
|
||||
container.addSubview(banner)
|
||||
NSLayoutConstraint.activate([
|
||||
banner.centerXAnchor.constraint(equalTo: container.centerXAnchor),
|
||||
banner.centerYAnchor.constraint(equalTo: container.centerYAnchor),
|
||||
banner.widthAnchor.constraint(equalToConstant: adSize.size.width),
|
||||
banner.heightAnchor.constraint(equalToConstant: adSize.size.height)
|
||||
])
|
||||
|
||||
context.coordinator.loadBannerIfPossible()
|
||||
return container
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {
|
||||
context.coordinator.bannerView?.rootViewController = rootViewController()
|
||||
context.coordinator.loadBannerIfPossible()
|
||||
}
|
||||
|
||||
private func rootViewController() -> UIViewController? {
|
||||
UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap(\.windows)
|
||||
.first(where: \.isKeyWindow)?
|
||||
.rootViewController
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, GADBannerViewDelegate {
|
||||
@Binding var state: BannerState
|
||||
weak var bannerView: GADBannerView?
|
||||
private var didRequestLoad = false
|
||||
private var retryCount = 0
|
||||
|
||||
init(state: Binding<BannerState>) {
|
||||
self._state = state
|
||||
}
|
||||
|
||||
func loadBannerIfPossible() {
|
||||
guard let bannerView, bannerView.rootViewController != nil else { return }
|
||||
guard !didRequestLoad else { return }
|
||||
didRequestLoad = true
|
||||
state = .loading
|
||||
bannerView.load(GADRequest())
|
||||
}
|
||||
|
||||
func bannerViewDidReceiveAd(_ bannerView: GADBannerView) {
|
||||
state = .loaded
|
||||
retryCount = 0
|
||||
}
|
||||
|
||||
func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: any Error) {
|
||||
state = .failed
|
||||
didRequestLoad = false
|
||||
|
||||
if retryCount < 2 {
|
||||
retryCount += 1
|
||||
loadBannerIfPossible()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
print("AdMob banner failed (\(retryCount)): \(error.localizedDescription)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
struct AdBannerView: View {
|
||||
var adUnitId: String = AdMobConfig.resolvedBannerHomeUnitId
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(Color.mealMoodTextSecondary.opacity(0.22))
|
||||
.frame(height: 1)
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "megaphone")
|
||||
Text("ads_unavailable")
|
||||
.font(.mealMoodCaption)
|
||||
}
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.frame(maxWidth: .infinity, minHeight: 56)
|
||||
.background(Color.mealMoodSurface)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.mealMoodSurface)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,9 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppIconPlaceholder: View {
|
||||
var size: CGFloat = 120
|
||||
|
||||
var body: some View {
|
||||
MealMoodLogoMark(size: size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EmptySlotView: View {
|
||||
let mealType: MealType
|
||||
let isDropTarget: Bool
|
||||
let isValidDrop: Bool?
|
||||
|
||||
init(mealType: MealType, isDropTarget: Bool = false, isValidDrop: Bool? = nil) {
|
||||
self.mealType = mealType
|
||||
self.isDropTarget = isDropTarget
|
||||
self.isValidDrop = isValidDrop
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: mealType.icon)
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(Color(hex: "#C4C4C4"))
|
||||
|
||||
Text(LocalizedStringKey(mealType.rawValue))
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 80)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(hex: "#F9F9F9"))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(
|
||||
style: StrokeStyle(lineWidth: 2, dash: [5])
|
||||
)
|
||||
.foregroundColor(borderColor)
|
||||
)
|
||||
)
|
||||
.animation(.easeInOut(duration: 0.2), value: isDropTarget)
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
guard isDropTarget, let valid = isValidDrop else {
|
||||
return Color(hex: "#E0E0E0")
|
||||
}
|
||||
return valid ? .mealMoodSuccess : .mealMoodError
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FilledSlotView: View {
|
||||
let dishName: String
|
||||
let dishDescription: String?
|
||||
let tags: [(name: String, color: String)]
|
||||
let mealType: MealType
|
||||
var showsRuleWarning: Bool = false
|
||||
var onRemove: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Image(systemName: mealType.icon)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
|
||||
if showsRuleWarning {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.mealMoodWarning)
|
||||
}
|
||||
Spacer()
|
||||
if onRemove != nil {
|
||||
Button {
|
||||
onRemove?()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.mealMoodTextSecondary.opacity(0.5))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(dishName)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
HStack(spacing: 3) {
|
||||
ForEach(Array(tags.prefix(2).enumerated()), id: \.offset) { _, tag in
|
||||
TagDot(color: tag.color, size: 9)
|
||||
}
|
||||
if tags.count > 2 {
|
||||
Text("+\(tags.count - 2)")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, minHeight: 80, alignment: .topLeading)
|
||||
.mealCardStyle()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MealMoodLogoMark: View {
|
||||
var size: CGFloat = 120
|
||||
|
||||
var body: some View {
|
||||
Image("AppLogo")
|
||||
.resizable()
|
||||
.interpolation(.high)
|
||||
.scaledToFill()
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(RoundedRectangle(cornerRadius: size * 0.22, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.12), radius: size * 0.08, y: size * 0.04)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PremiumUpsellBanner: View {
|
||||
let messageKey: LocalizedStringKey
|
||||
let actionTitleKey: LocalizedStringKey
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "star.circle")
|
||||
Text(messageKey)
|
||||
Spacer()
|
||||
Button(actionTitleKey, action: action)
|
||||
.font(.mealMoodCaption)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
.padding(12)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PrimaryButton: View {
|
||||
let title: String
|
||||
var icon: String? = nil
|
||||
let action: () -> Void
|
||||
var isEnabled: Bool = true
|
||||
var localizeTitle: Bool = true
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
Group {
|
||||
if localizeTitle {
|
||||
Text(LocalizedStringKey(title))
|
||||
} else {
|
||||
Text(verbatim: title)
|
||||
}
|
||||
}
|
||||
.font(.mealMoodBodyBold)
|
||||
if let icon = icon {
|
||||
Text(icon)
|
||||
}
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 14)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: isEnabled
|
||||
? [Color.mealMoodCoral, Color.mealMoodMint]
|
||||
: [Color.gray.opacity(0.3), Color.gray.opacity(0.3)],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.cornerRadius(14)
|
||||
.shadow(color: isEnabled ? Color.mealMoodCoral.opacity(0.3) : .clear, radius: 8, y: 4)
|
||||
}
|
||||
.disabled(!isEnabled)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SecondaryButton: View {
|
||||
let title: String
|
||||
var icon: String? = nil
|
||||
let action: () -> Void
|
||||
var localizeTitle: Bool = true
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
Group {
|
||||
if localizeTitle {
|
||||
Text(LocalizedStringKey(title))
|
||||
} else {
|
||||
Text(verbatim: title)
|
||||
}
|
||||
}
|
||||
.font(.mealMoodBodyBold)
|
||||
if let icon = icon {
|
||||
Text(icon)
|
||||
}
|
||||
}
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 14)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.mealMoodSurface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(Color.mealMoodTextSecondary.opacity(0.3), lineWidth: 1.5)
|
||||
)
|
||||
.cornerRadius(14)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TagPill: View {
|
||||
let name: String
|
||||
let color: String
|
||||
|
||||
var body: some View {
|
||||
Text(name)
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color(hex: color))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct TagDot: View {
|
||||
let color: String
|
||||
var size: CGFloat = 10
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(Color(hex: color))
|
||||
.frame(width: size, height: size)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.7), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ToastView: View {
|
||||
let message: String
|
||||
@Binding var isShowing: Bool
|
||||
|
||||
var body: some View {
|
||||
if isShowing {
|
||||
VStack {
|
||||
HStack(spacing: 8) {
|
||||
Text(message)
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.mealMoodCoral.opacity(0.95))
|
||||
.shadow(color: .black.opacity(0.1), radius: 8, y: 4)
|
||||
)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
withAnimation(.easeOut) {
|
||||
isShowing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ToastModifier: ViewModifier {
|
||||
@Binding var isShowing: Bool
|
||||
let message: String
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
ZStack {
|
||||
content
|
||||
ToastView(message: message, isShowing: $isShowing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func toast(isShowing: Binding<Bool>, message: String) -> some View {
|
||||
modifier(ToastModifier(isShowing: isShowing, message: message))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"appPolicies" : {
|
||||
"eula" : "",
|
||||
"policies" : [
|
||||
{
|
||||
"locale" : "en_US",
|
||||
"policyText" : "",
|
||||
"policyURL" : ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"identifier" : "8F03B81B-97EF-4A5B-A3E7-4B2036B0C8D7",
|
||||
"nonRenewingSubscriptions" : [
|
||||
|
||||
],
|
||||
"products" : [
|
||||
|
||||
],
|
||||
"settings" : {
|
||||
"_applicationInternalID" : "2147483647",
|
||||
"_askToBuyEnabled" : false,
|
||||
"_billingGracePeriodEnabled" : false,
|
||||
"_billingIssuesEnabled" : false,
|
||||
"_developerTeamID" : "",
|
||||
"_disableDialogs" : false,
|
||||
"_failTransactionsEnabled" : false,
|
||||
"_lastSynchronizedDate" : 0,
|
||||
"_locale" : "en_US",
|
||||
"_renewalBillingIssuesEnabled" : false,
|
||||
"_storefront" : "USA",
|
||||
"_storeKitErrors" : [
|
||||
|
||||
],
|
||||
"_timeRate" : 0
|
||||
},
|
||||
"subscriptionGroups" : [
|
||||
{
|
||||
"id" : "967D2C7C-7AD9-4680-B989-9DE8C0ED7F77",
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Unlock MealMood Premium features.",
|
||||
"displayName" : "MealMood Premium",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"name" : "MealMood Premium",
|
||||
"subscriptions" : [
|
||||
{
|
||||
"adHocOffers" : [],
|
||||
"codeOffers" : [],
|
||||
"displayPrice" : "2.99",
|
||||
"familyShareable" : true,
|
||||
"groupNumber" : 1,
|
||||
"internalID" : "1A32C57F-C4E9-49F8-8F72-28A2B8A1CC4A",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Monthly subscription to unlock all premium features.",
|
||||
"displayName" : "MealMood Premium Monthly",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "com.mealmood.premium.monthly",
|
||||
"recurringSubscriptionPeriod" : "P1M",
|
||||
"referenceName" : "MealMood Premium Monthly",
|
||||
"subscriptionPricePointID" : "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"version" : {
|
||||
"major" : 4,
|
||||
"minor" : 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"appPolicies" : {
|
||||
"eula" : "",
|
||||
"policies" : [
|
||||
{
|
||||
"locale" : "en_US",
|
||||
"policyText" : "",
|
||||
"policyURL" : ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"identifier" : "F6A6B4E2-29F3-4F28-B9A9-6E6B955B3127",
|
||||
"nonRenewingSubscriptions" : [
|
||||
|
||||
],
|
||||
"products" : [
|
||||
|
||||
],
|
||||
"settings" : {
|
||||
"_applicationInternalID" : "2147483647",
|
||||
"_askToBuyEnabled" : false,
|
||||
"_billingGracePeriodEnabled" : false,
|
||||
"_billingIssuesEnabled" : false,
|
||||
"_developerTeamID" : "",
|
||||
"_disableDialogs" : false,
|
||||
"_failTransactionsEnabled" : false,
|
||||
"_lastSynchronizedDate" : 0,
|
||||
"_locale" : "en_US",
|
||||
"_renewalBillingIssuesEnabled" : false,
|
||||
"_storefront" : "USA",
|
||||
"_storeKitErrors" : [
|
||||
|
||||
],
|
||||
"_timeRate" : 0
|
||||
},
|
||||
"subscriptionGroups" : [
|
||||
{
|
||||
"id" : "B967A33A-84E1-4A23-BDC4-EF4B0DE6DD11",
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Unlock MealMood Premium features.",
|
||||
"displayName" : "MealMood Premium",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"name" : "MealMood Premium",
|
||||
"subscriptions" : [
|
||||
{
|
||||
"adHocOffers" : [],
|
||||
"codeOffers" : [],
|
||||
"displayPrice" : "2.99",
|
||||
"familyShareable" : true,
|
||||
"groupNumber" : 1,
|
||||
"internalID" : "0B21FC67-2BB9-4A11-8A95-8F2D568A07A0",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Monthly subscription to unlock all premium features.",
|
||||
"displayName" : "MealMood Premium Monthly",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "com.mealmood.premium.monthly",
|
||||
"recurringSubscriptionPeriod" : "P1M",
|
||||
"referenceName" : "MealMood Premium Monthly",
|
||||
"subscriptionPricePointID" : "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"version" : {
|
||||
"major" : 4,
|
||||
"minor" : 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Query private var allSettings: [AppSettings]
|
||||
@State private var isReady = false
|
||||
|
||||
private var settings: AppSettings? { allSettings.first }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if !isReady {
|
||||
// Splash screen
|
||||
ZStack {
|
||||
Color.mealMoodBackground.ignoresSafeArea()
|
||||
AppIconPlaceholder(size: 120)
|
||||
}
|
||||
} else if let settings = settings, settings.onboardingCompleted {
|
||||
HomeView()
|
||||
} else {
|
||||
OnboardingView {
|
||||
// Onboarding completed
|
||||
}
|
||||
}
|
||||
}
|
||||
.environment(\.locale, Locale(identifier: settings?.languageEnum.localeIdentifier ?? Locale.current.identifier))
|
||||
.onAppear {
|
||||
initializeAppIfNeeded()
|
||||
Task {
|
||||
if settings?.iCloudSyncEnabledResolved ?? true {
|
||||
await ICloudSyncService.shared.pullRemoteIfNeeded(context: context)
|
||||
}
|
||||
await syncPremiumStatus()
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
withAnimation(.easeInOut) {
|
||||
isReady = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
Task {
|
||||
switch phase {
|
||||
case .active:
|
||||
if settings?.iCloudSyncEnabledResolved ?? true {
|
||||
await ICloudSyncService.shared.pullRemoteIfNeeded(context: context)
|
||||
}
|
||||
await syncPremiumStatus()
|
||||
case .inactive, .background:
|
||||
if settings?.iCloudSyncEnabledResolved ?? true {
|
||||
await ICloudSyncService.shared.pushLocalSnapshot(context: context)
|
||||
}
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func initializeAppIfNeeded() {
|
||||
// Create default settings if none exist
|
||||
if allSettings.isEmpty {
|
||||
DefaultDataService.createDefaultSettings(context: context)
|
||||
}
|
||||
|
||||
// Create default tags if none exist
|
||||
let tagDescriptor = FetchDescriptor<Tag>()
|
||||
if (try? context.fetch(tagDescriptor))?.isEmpty ?? true {
|
||||
DefaultDataService.createDefaultTags(context: context)
|
||||
}
|
||||
}
|
||||
|
||||
private func syncPremiumStatus() async {
|
||||
let descriptor = FetchDescriptor<AppSettings>()
|
||||
guard let settings = try? context.fetch(descriptor).first else { return }
|
||||
let premium = await StoreManager.hasActiveSubscription()
|
||||
if settings.isPremium != premium {
|
||||
settings.isPremium = premium
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
// Primary
|
||||
static let mealMoodCoral = Color(hex: "#FFB4A2")
|
||||
static let mealMoodMint = Color(hex: "#B8E6D5")
|
||||
|
||||
// Background
|
||||
static let mealMoodBackground = Color(hex: "#FFF9F5")
|
||||
static let mealMoodSurface = Color.white
|
||||
|
||||
// Text
|
||||
static let mealMoodTextPrimary = Color(hex: "#2D2D2D")
|
||||
static let mealMoodTextSecondary = Color(hex: "#6B6B6B")
|
||||
|
||||
// Feedback
|
||||
static let mealMoodSuccess = Color(hex: "#A8D5BA")
|
||||
static let mealMoodWarning = Color(hex: "#FFD6A5")
|
||||
static let mealMoodError = Color(hex: "#FF9999")
|
||||
|
||||
init(hex: String) {
|
||||
let scanner = Scanner(string: hex)
|
||||
scanner.currentIndex = hex.hasPrefix("#") ? hex.index(after: hex.startIndex) : hex.startIndex
|
||||
var rgb: UInt64 = 0
|
||||
scanner.scanHexInt64(&rgb)
|
||||
let r = Double((rgb & 0xFF0000) >> 16) / 255.0
|
||||
let g = Double((rgb & 0x00FF00) >> 8) / 255.0
|
||||
let b = Double(rgb & 0x0000FF) / 255.0
|
||||
self.init(red: r, green: g, blue: b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
func addingDays(_ days: Int) -> Date {
|
||||
Calendar.current.date(byAdding: .day, value: days, to: self)!
|
||||
}
|
||||
|
||||
func addingMinutes(_ minutes: Int) -> Date {
|
||||
Calendar.current.date(byAdding: .minute, value: minutes, to: self)!
|
||||
}
|
||||
|
||||
func startOfWeek() -> Date {
|
||||
let calendar = Calendar.current
|
||||
var cal = calendar
|
||||
cal.firstWeekday = 2 // Monday
|
||||
let components = cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: self)
|
||||
return cal.date(from: components) ?? self
|
||||
}
|
||||
|
||||
func dayOfWeekIndex() -> Int {
|
||||
let calendar = Calendar.current
|
||||
let weekday = calendar.component(.weekday, from: self)
|
||||
// Convert to 0=Monday format
|
||||
return (weekday + 5) % 7
|
||||
}
|
||||
|
||||
var isToday: Bool {
|
||||
Calendar.current.isDateInToday(self)
|
||||
}
|
||||
|
||||
var isPast: Bool {
|
||||
self < Calendar.current.startOfDay(for: Date())
|
||||
}
|
||||
|
||||
func formattedWeekRange() -> String {
|
||||
let endDate = self.addingDays(6)
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale.current
|
||||
|
||||
let dayFormatter = DateFormatter()
|
||||
dayFormatter.dateFormat = "d"
|
||||
|
||||
let monthFormatter = DateFormatter()
|
||||
monthFormatter.dateFormat = "MMM"
|
||||
|
||||
let startDay = dayFormatter.string(from: self)
|
||||
let endDay = dayFormatter.string(from: endDate)
|
||||
let month = monthFormatter.string(from: endDate)
|
||||
|
||||
return "\(startDay)-\(endDay) \(month)"
|
||||
}
|
||||
|
||||
func startOfMonth() -> Date {
|
||||
let calendar = Calendar.current
|
||||
let components = calendar.dateComponents([.year, .month], from: self)
|
||||
return calendar.date(from: components) ?? self
|
||||
}
|
||||
|
||||
func addingMonths(_ months: Int) -> Date {
|
||||
Calendar.current.date(byAdding: .month, value: months, to: self) ?? self
|
||||
}
|
||||
|
||||
func monthYearLabel(locale: Locale = .current) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = locale
|
||||
formatter.setLocalizedDateFormatFromTemplate("LLLL yyyy")
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
}
|
||||
|
||||
func combineDateAndTime(date: Date, time: Date) -> Date {
|
||||
let calendar = Calendar.current
|
||||
let dateComponents = calendar.dateComponents([.year, .month, .day], from: date)
|
||||
let timeComponents = calendar.dateComponents([.hour, .minute], from: time)
|
||||
|
||||
var combined = DateComponents()
|
||||
combined.year = dateComponents.year
|
||||
combined.month = dateComponents.month
|
||||
combined.day = dateComponents.day
|
||||
combined.hour = timeComponents.hour
|
||||
combined.minute = timeComponents.minute
|
||||
|
||||
return calendar.date(from: combined) ?? date
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Font {
|
||||
static let mealMoodH1 = Font.system(size: 28, weight: .bold, design: .rounded)
|
||||
static let mealMoodH2 = Font.system(size: 20, weight: .semibold, design: .rounded)
|
||||
static let mealMoodH3 = Font.system(size: 18, weight: .semibold, design: .rounded)
|
||||
|
||||
static let mealMoodBody = Font.system(size: 16, weight: .regular, design: .default)
|
||||
static let mealMoodBodyBold = Font.system(size: 16, weight: .semibold, design: .default)
|
||||
|
||||
static let mealMoodCaption = Font.system(size: 12, weight: .medium, design: .default)
|
||||
static let mealMoodSmall = Font.system(size: 14, weight: .regular, design: .default)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
func localizedString(_ key: String, language: AppLanguage) -> String {
|
||||
let resolvedLanguage = language.resolved()
|
||||
|
||||
if let path = Bundle.main.path(forResource: resolvedLanguage.localeIdentifier, ofType: "lproj"),
|
||||
let bundle = Bundle(path: path) {
|
||||
let localized = bundle.localizedString(forKey: key, value: nil, table: nil)
|
||||
if localized != key {
|
||||
return localized
|
||||
}
|
||||
}
|
||||
|
||||
if let path = Bundle.main.path(forResource: "en", ofType: "lproj"),
|
||||
let bundle = Bundle(path: path) {
|
||||
let english = bundle.localizedString(forKey: key, value: nil, table: nil)
|
||||
if english != key {
|
||||
return english
|
||||
}
|
||||
}
|
||||
|
||||
return NSLocalizedString(key, comment: "")
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MealCardStyle: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(16)
|
||||
.shadow(color: Color.black.opacity(0.05), radius: 8, y: 4)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func mealCardStyle() -> some View {
|
||||
modifier(MealCardStyle())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
#if canImport(GoogleMobileAds)
|
||||
import GoogleMobileAds
|
||||
#endif
|
||||
|
||||
@main
|
||||
struct MealMoodApp: App {
|
||||
private let modelContainer: ModelContainer = {
|
||||
let schema = Schema([
|
||||
AppSettings.self,
|
||||
Tag.self,
|
||||
Dish.self,
|
||||
WeekPlan.self,
|
||||
MealSlot.self
|
||||
])
|
||||
let configuration = ModelConfiguration(cloudKitDatabase: .none)
|
||||
return try! ModelContainer(for: schema, configurations: [configuration])
|
||||
}()
|
||||
|
||||
init() {
|
||||
#if canImport(GoogleMobileAds)
|
||||
GADMobileAds.sharedInstance().start(completionHandler: nil)
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
.modelContainer(modelContainer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class AppSettings {
|
||||
var mealWindows: String // "dinnerOnly", "lunchOnly", "both"
|
||||
var includeWeekends: Bool
|
||||
var language: String // "spanish", "english"
|
||||
|
||||
var calendarId: String?
|
||||
var syncEnabled: Bool
|
||||
var syncMode: String? // "weekComplete", "manual"
|
||||
var lunchTime: Date
|
||||
var dinnerTime: Date
|
||||
var eventDuration: Int
|
||||
var eventPrefix: String
|
||||
var reminderMinutesBefore: Int?
|
||||
var iCloudSyncEnabled: Bool?
|
||||
|
||||
var isPremium: Bool
|
||||
var onboardingCompleted: Bool
|
||||
|
||||
init() {
|
||||
self.mealWindows = "dinnerOnly"
|
||||
self.includeWeekends = true
|
||||
self.language = "system"
|
||||
self.calendarId = nil
|
||||
self.syncEnabled = false
|
||||
self.syncMode = CalendarSyncMode.weekComplete.rawValue
|
||||
// Default lunch time 14:00
|
||||
var lunchComponents = DateComponents()
|
||||
lunchComponents.hour = 14
|
||||
lunchComponents.minute = 0
|
||||
self.lunchTime = Calendar.current.date(from: lunchComponents) ?? Date()
|
||||
// Default dinner time 21:00
|
||||
var dinnerComponents = DateComponents()
|
||||
dinnerComponents.hour = 21
|
||||
dinnerComponents.minute = 0
|
||||
self.dinnerTime = Calendar.current.date(from: dinnerComponents) ?? Date()
|
||||
self.eventDuration = 60
|
||||
self.eventPrefix = "🍽️"
|
||||
self.reminderMinutesBefore = nil
|
||||
self.iCloudSyncEnabled = true
|
||||
self.isPremium = false
|
||||
self.onboardingCompleted = false
|
||||
}
|
||||
|
||||
var mealWindowsEnum: MealWindows {
|
||||
get { MealWindows(rawValue: mealWindows) ?? .dinnerOnly }
|
||||
set { mealWindows = newValue.rawValue }
|
||||
}
|
||||
|
||||
var languageEnum: AppLanguage {
|
||||
get { AppLanguage(rawValue: language) ?? .system }
|
||||
set { language = newValue.rawValue }
|
||||
}
|
||||
|
||||
var syncModeEnum: CalendarSyncMode {
|
||||
get { CalendarSyncMode(rawValue: syncMode ?? "") ?? .weekComplete }
|
||||
set { syncMode = newValue.rawValue }
|
||||
}
|
||||
|
||||
var iCloudSyncEnabledResolved: Bool {
|
||||
get { iCloudSyncEnabled ?? true }
|
||||
set { iCloudSyncEnabled = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
enum MealWindows: String, CaseIterable {
|
||||
case dinnerOnly
|
||||
case lunchOnly
|
||||
case both
|
||||
|
||||
var localizedKey: String {
|
||||
switch self {
|
||||
case .dinnerOnly: return "meal_windows_dinner_only"
|
||||
case .lunchOnly: return "meal_windows_lunch_only"
|
||||
case .both: return "meal_windows_both"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .dinnerOnly: return "moon.stars.fill"
|
||||
case .lunchOnly: return "sun.max.fill"
|
||||
case .both: return "sun.and.horizon.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppLanguage: String, CaseIterable {
|
||||
case system
|
||||
case spanish
|
||||
case english
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .system: return "System"
|
||||
case .spanish: return "Español"
|
||||
case .english: return "English"
|
||||
}
|
||||
}
|
||||
|
||||
var localeIdentifier: String {
|
||||
switch self {
|
||||
case .system: return resolved().localeIdentifier
|
||||
case .spanish: return "es"
|
||||
case .english: return "en"
|
||||
}
|
||||
}
|
||||
|
||||
func resolved() -> AppLanguage {
|
||||
if self != .system { return self }
|
||||
return Locale.current.identifier.lowercased().hasPrefix("es") ? .spanish : .english
|
||||
}
|
||||
}
|
||||
|
||||
enum MealType: String, Codable, CaseIterable {
|
||||
case lunch
|
||||
case dinner
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .lunch: return "sun.max.fill"
|
||||
case .dinner: return "moon.stars.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CalendarSyncMode: String, CaseIterable {
|
||||
case weekComplete
|
||||
case manual
|
||||
|
||||
var localizedKey: String {
|
||||
switch self {
|
||||
case .weekComplete: return "settings_sync_mode_week_complete"
|
||||
case .manual: return "settings_sync_mode_manual"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class Dish {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var name: String
|
||||
var descriptionText: String?
|
||||
var tagIds: [UUID]
|
||||
var createdAt: Date
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
descriptionText: String? = nil,
|
||||
tagIds: [UUID] = [],
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.descriptionText = descriptionText
|
||||
self.tagIds = tagIds
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class MealSlot {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var dayOfWeek: Int // 0=Monday, 6=Sunday
|
||||
var mealType: String // "lunch", "dinner"
|
||||
var dishId: UUID?
|
||||
var calendarEventId: String?
|
||||
var isRuleOverridden: Bool
|
||||
|
||||
var weekPlan: WeekPlan?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
dayOfWeek: Int,
|
||||
mealType: String,
|
||||
dishId: UUID? = nil,
|
||||
calendarEventId: String? = nil,
|
||||
isRuleOverridden: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.dayOfWeek = dayOfWeek
|
||||
self.mealType = mealType
|
||||
self.dishId = dishId
|
||||
self.calendarEventId = calendarEventId
|
||||
self.isRuleOverridden = isRuleOverridden
|
||||
}
|
||||
|
||||
var mealTypeEnum: MealType {
|
||||
get { MealType(rawValue: mealType) ?? .dinner }
|
||||
set { mealType = newValue.rawValue }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class Tag {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var name: String
|
||||
var nameEN: String
|
||||
var color: String
|
||||
var maxPerWeek: Int?
|
||||
var noConsecutive: Bool
|
||||
var noDuplicateInDay: Bool
|
||||
var mealTypeRestriction: String? // "lunch", "dinner", nil
|
||||
var isDefault: Bool
|
||||
var sortOrder: Int
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
nameEN: String = "",
|
||||
color: String,
|
||||
maxPerWeek: Int? = nil,
|
||||
noConsecutive: Bool = false,
|
||||
noDuplicateInDay: Bool = false,
|
||||
mealTypeRestriction: String? = nil,
|
||||
isDefault: Bool = true,
|
||||
sortOrder: Int = 0
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.nameEN = nameEN
|
||||
self.color = color
|
||||
self.maxPerWeek = maxPerWeek
|
||||
self.noConsecutive = noConsecutive
|
||||
self.noDuplicateInDay = noDuplicateInDay
|
||||
self.mealTypeRestriction = mealTypeRestriction
|
||||
self.isDefault = isDefault
|
||||
self.sortOrder = sortOrder
|
||||
}
|
||||
|
||||
func localizedName(language: AppLanguage) -> String {
|
||||
switch language.resolved() {
|
||||
case .spanish: return name
|
||||
case .english: return nameEN.isEmpty ? name : nameEN
|
||||
case .system: return name
|
||||
}
|
||||
}
|
||||
|
||||
var rulesDescription: String {
|
||||
localizedRulesDescription(language: .spanish)
|
||||
}
|
||||
|
||||
func localizedRulesDescription(language: AppLanguage) -> String {
|
||||
let resolvedLanguage = language.resolved()
|
||||
var parts: [String] = []
|
||||
if let max = maxPerWeek {
|
||||
switch resolvedLanguage {
|
||||
case .spanish:
|
||||
parts.append("Max \(max)/semana")
|
||||
case .english:
|
||||
parts.append("Max \(max)/week")
|
||||
case .system:
|
||||
parts.append("Max \(max)/week")
|
||||
}
|
||||
}
|
||||
if noConsecutive {
|
||||
parts.append(resolvedLanguage == .spanish ? "no consecutivo" : "no consecutive")
|
||||
}
|
||||
if noDuplicateInDay {
|
||||
parts.append(resolvedLanguage == .spanish ? "no mismo día" : "no same day")
|
||||
}
|
||||
if let restriction = mealTypeRestriction {
|
||||
if resolvedLanguage == .spanish {
|
||||
parts.append(restriction == "lunch" ? "solo comida" : "solo cena")
|
||||
} else {
|
||||
parts.append(restriction == "lunch" ? "lunch only" : "dinner only")
|
||||
}
|
||||
}
|
||||
return parts.isEmpty ? (resolvedLanguage == .spanish ? "Sin límite" : "No limit") : parts.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class WeekPlan {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var weekStartDate: Date
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
|
||||
@Relationship(deleteRule: .cascade)
|
||||
var slots: [MealSlot]
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
weekStartDate: Date,
|
||||
slots: [MealSlot] = [],
|
||||
createdAt: Date = Date(),
|
||||
updatedAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.weekStartDate = weekStartDate
|
||||
self.slots = slots
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "idiom" : "iphone", "size" : "20x20", "scale" : "2x", "filename" : "icon-20@2x.png" },
|
||||
{ "idiom" : "iphone", "size" : "20x20", "scale" : "3x", "filename" : "icon-20@3x.png" },
|
||||
{ "idiom" : "iphone", "size" : "29x29", "scale" : "2x", "filename" : "icon-29@2x.png" },
|
||||
{ "idiom" : "iphone", "size" : "29x29", "scale" : "3x", "filename" : "icon-29@3x.png" },
|
||||
{ "idiom" : "iphone", "size" : "40x40", "scale" : "2x", "filename" : "icon-40@2x.png" },
|
||||
{ "idiom" : "iphone", "size" : "40x40", "scale" : "3x", "filename" : "icon-40@3x.png" },
|
||||
{ "idiom" : "iphone", "size" : "60x60", "scale" : "2x", "filename" : "icon-60@2x.png" },
|
||||
{ "idiom" : "iphone", "size" : "60x60", "scale" : "3x", "filename" : "icon-60@3x.png" },
|
||||
|
||||
{ "idiom" : "ipad", "size" : "20x20", "scale" : "1x", "filename" : "icon-20@1x-ipad.png" },
|
||||
{ "idiom" : "ipad", "size" : "20x20", "scale" : "2x", "filename" : "icon-20@2x-ipad.png" },
|
||||
{ "idiom" : "ipad", "size" : "29x29", "scale" : "1x", "filename" : "icon-29@1x-ipad.png" },
|
||||
{ "idiom" : "ipad", "size" : "29x29", "scale" : "2x", "filename" : "icon-29@2x-ipad.png" },
|
||||
{ "idiom" : "ipad", "size" : "40x40", "scale" : "1x", "filename" : "icon-40@1x-ipad.png" },
|
||||
{ "idiom" : "ipad", "size" : "40x40", "scale" : "2x", "filename" : "icon-40@2x-ipad.png" },
|
||||
{ "idiom" : "ipad", "size" : "76x76", "scale" : "1x", "filename" : "icon-76@1x.png" },
|
||||
{ "idiom" : "ipad", "size" : "76x76", "scale" : "2x", "filename" : "icon-76@2x.png" },
|
||||
{ "idiom" : "ipad", "size" : "83.5x83.5", "scale" : "2x", "filename" : "icon-83.5@2x.png" },
|
||||
|
||||
{ "idiom" : "ios-marketing", "size" : "1024x1024", "scale" : "1x", "filename" : "icon-1024.png" }
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 681 KiB |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 681 KiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "AppLogo.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="140" y1="120" x2="900" y2="900" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#FFB4A2"/>
|
||||
<stop offset="1" stop-color="#B8E6D5"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="80" y="80" width="864" height="864" filterUnits="userSpaceOnUse">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="20" flood-color="#000000" flood-opacity="0.10"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<circle cx="512" cy="512" r="390" fill="url(#bg)"/>
|
||||
</g>
|
||||
|
||||
<!-- Plate -->
|
||||
<circle cx="512" cy="512" r="220" stroke="white" stroke-width="28"/>
|
||||
<circle cx="512" cy="512" r="142" stroke="white" stroke-width="14" opacity="0.9"/>
|
||||
|
||||
<!-- Fork handle -->
|
||||
<line x1="358" y1="676" x2="526" y2="452" stroke="white" stroke-width="36" stroke-linecap="round"/>
|
||||
<!-- Fork head -->
|
||||
<line x1="316" y1="450" x2="382" y2="500" stroke="white" stroke-width="12" stroke-linecap="round"/>
|
||||
<line x1="334" y1="426" x2="400" y2="476" stroke="white" stroke-width="12" stroke-linecap="round"/>
|
||||
<line x1="352" y1="402" x2="418" y2="452" stroke="white" stroke-width="12" stroke-linecap="round"/>
|
||||
|
||||
<!-- Spoon handle -->
|
||||
<line x1="670" y1="694" x2="502" y2="470" stroke="white" stroke-width="36" stroke-linecap="round"/>
|
||||
<!-- Spoon bowl -->
|
||||
<ellipse cx="642" cy="430" rx="44" ry="62" transform="rotate(32 642 430)" fill="white"/>
|
||||
|
||||
<!-- Sparkle -->
|
||||
<path d="M752 248L770 300L822 318L770 336L752 388L734 336L682 318L734 300L752 248Z" fill="white"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>GADApplicationIdentifier</key>
|
||||
<string>ca-app-pub-1549720748100858~9985112590</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCalendarsFullAccessUsageDescription</key>
|
||||
<string>MealMood needs calendar access to sync your meal plan.</string>
|
||||
<key>NSCalendarsWriteOnlyAccessUsageDescription</key>
|
||||
<string>MealMood needs calendar access to create meal events.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<true/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
<key>UIStatusBarStyle</key>
|
||||
<string>UIStatusBarStyleDefault</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.com.alexandrev.mealmood</string>
|
||||
</array>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,244 @@
|
||||
/* Onboarding */
|
||||
"onboarding_welcome_title" = "MealMood";
|
||||
"onboarding_welcome_subtitle" = "Plan your week, enjoy your meals";
|
||||
"onboarding_welcome_benefit1" = "No stress";
|
||||
"onboarding_welcome_benefit2" = "No calorie counting";
|
||||
"onboarding_welcome_benefit3" = "Sync with your calendar";
|
||||
"onboarding_start" = "Get Started";
|
||||
"onboarding_continue" = "Continue";
|
||||
"onboarding_skip" = "Skip";
|
||||
"onboarding_finish" = "Finish";
|
||||
|
||||
/* Meal Windows */
|
||||
"meal_windows_title" = "What do you want to plan?";
|
||||
"meal_windows_dinner_only" = "Dinners only";
|
||||
"meal_windows_lunch_only" = "Lunches only";
|
||||
"meal_windows_both" = "Lunches and dinners";
|
||||
|
||||
/* Weekends */
|
||||
"weekends_title" = "Include weekends?";
|
||||
"weekends_toggle" = "Also plan Saturdays and Sundays";
|
||||
"weekends_note" = "You can change this later in settings";
|
||||
|
||||
/* Calendar */
|
||||
"calendar_title" = "Sync with your calendar?";
|
||||
"calendar_sync" = "Sync";
|
||||
"calendar_select" = "Calendar";
|
||||
"calendar_lunch_time" = "Lunch time";
|
||||
"calendar_dinner_time" = "Dinner time";
|
||||
"calendar_optional" = "Optional, you can enable it later";
|
||||
"calendar_permission_title" = "Calendar Access";
|
||||
"calendar_permission_message" = "MealMood needs access to your calendar to sync events.";
|
||||
"calendar_permission_settings" = "Go to Settings";
|
||||
|
||||
/* First Dishes */
|
||||
"first_dishes_title" = "Add your first dishes";
|
||||
"first_dishes_subtitle" = "Add at least 2 dishes to get started";
|
||||
"first_dishes_name_placeholder" = "Dish name";
|
||||
"first_dishes_add_tag" = "Add tag";
|
||||
"first_dishes_add_dish" = "Add dish";
|
||||
"first_dishes_added" = "Dishes added:";
|
||||
|
||||
/* Home */
|
||||
"home_title" = "MealMood";
|
||||
"home_previous" = "Previous";
|
||||
"home_next" = "Next";
|
||||
"home_complete" = "Complete";
|
||||
"home_undo_last_action" = "Undo last action";
|
||||
"home_reset" = "Reset";
|
||||
"home_copy_previous_week" = "Copy previous week";
|
||||
"home_my_dishes" = "My dishes";
|
||||
"home_add_first_dish" = "Add your first dish to get started";
|
||||
"home_add_dish" = "Add dish";
|
||||
"home_my_dishes_search" = "Search dishes";
|
||||
"home_my_dishes_search_empty" = "No dishes found";
|
||||
"home_my_dishes_hide_used" = "Hide used this week";
|
||||
"home_pick_dish_title" = "Choose a dish";
|
||||
"home_pick_dish_search" = "Search dishes";
|
||||
"home_pick_dish_empty" = "No dishes match your search";
|
||||
"home_pick_dish_add_new" = "Add new dish";
|
||||
"home_pick_dish_no_dishes" = "No dishes yet. Add one to get started.";
|
||||
"home_select_week" = "Select week";
|
||||
"home_week_picker_title" = "Select week";
|
||||
"home_week_picker_date" = "Date";
|
||||
"home_week_picker_go" = "Go";
|
||||
|
||||
/* Reset Alert */
|
||||
"reset_title" = "Clear the entire week?";
|
||||
"reset_message" = "This cannot be undone";
|
||||
"reset_cancel" = "Cancel";
|
||||
"reset_confirm" = "Clear";
|
||||
|
||||
/* Toasts */
|
||||
"toast_dish_assigned" = "Dish assigned ✓";
|
||||
"toast_week_complete" = "Week complete! 🎉";
|
||||
"toast_week_reset" = "Week cleared 🔄";
|
||||
"toast_dish_saved" = "Dish saved ✓";
|
||||
"toast_event_deleted" = "Calendar event deleted";
|
||||
"toast_cannot_complete" = "Could not complete all slots";
|
||||
"toast_dish_deleted" = "Dish deleted";
|
||||
"toast_no_free_slots" = "No free slots available";
|
||||
"toast_calendar_synced" = "Calendar synced ✓";
|
||||
"toast_copied_previous_week" = "Copied from previous week ✓";
|
||||
"toast_previous_week_empty" = "No previous week plan found";
|
||||
"toast_undo_applied" = "Last action undone";
|
||||
"copy_previous_confirm_title" = "Replace current week?";
|
||||
"copy_previous_confirm_message" = "You already have assigned dishes. Copying the previous week will overwrite current assignments.";
|
||||
"copy_previous_confirm_confirm" = "Replace week";
|
||||
|
||||
/* Dish Form */
|
||||
"dish_new_title" = "New dish";
|
||||
"dish_edit_title" = "Edit dish";
|
||||
"dish_name_label" = "Dish name *";
|
||||
"dish_name_placeholder" = "E.g.: Roasted chicken";
|
||||
"dish_description_label" = "Description (optional)";
|
||||
"dish_description_placeholder" = "E.g.: Juicy and oven-baked with herbs";
|
||||
"dish_tags_label" = "Tags (optional)";
|
||||
"dish_add_tag" = "Add tag";
|
||||
"dish_delete" = "Delete dish";
|
||||
"dish_delete_title" = "Delete this dish?";
|
||||
"dish_delete_message" = "This action cannot be undone";
|
||||
"dish_cancel" = "Cancel";
|
||||
"dish_delete_blocked_title" = "Cannot delete";
|
||||
"dish_delete_blocked_message" = "You cannot delete a dish assigned in the current week.";
|
||||
"dish_delete_blocked_ok" = "OK";
|
||||
"dish_list_empty" = "You don't have dishes yet";
|
||||
|
||||
/* Tag Selector */
|
||||
"tag_selector_title" = "Select tags";
|
||||
"tag_selector_done" = "Done";
|
||||
|
||||
/* Tags */
|
||||
"tags_title" = "Tags";
|
||||
"tags_max_per_week" = "Max per week";
|
||||
"tags_no_consecutive" = "No consecutive";
|
||||
"tags_no_consecutive_desc" = "Prevents appearing on consecutive days";
|
||||
"tags_no_duplicate" = "No same day repeat";
|
||||
"tags_no_duplicate_desc" = "Cannot be in both lunch and dinner on the same day";
|
||||
"tags_restriction" = "Time restriction";
|
||||
"tags_no_restriction" = "No restriction";
|
||||
"tags_lunch_only" = "Lunch only";
|
||||
"tags_dinner_only" = "Dinner only";
|
||||
"tags_no_limit" = "No limit";
|
||||
|
||||
/* Settings */
|
||||
"settings_title" = "Settings";
|
||||
"settings_planning" = "Planning";
|
||||
"settings_meal_windows" = "Meal windows";
|
||||
"settings_include_weekends" = "Include weekends";
|
||||
"settings_calendar" = "Calendar";
|
||||
"settings_icloud_sync" = "Sync data with iCloud";
|
||||
"settings_sync" = "Sync";
|
||||
"settings_sync_mode" = "Sync mode";
|
||||
"settings_sync_mode_week_complete" = "Auto (when week is complete)";
|
||||
"settings_sync_mode_manual" = "Manual (sync button)";
|
||||
"settings_sync_now" = "Sync now";
|
||||
"settings_lunch_time" = "Lunch time";
|
||||
"settings_dinner_time" = "Dinner time";
|
||||
"settings_event_duration" = "Event duration";
|
||||
"settings_event_prefix" = "Title prefix";
|
||||
"settings_reminder" = "Reminder";
|
||||
"settings_reminder_none" = "No reminder";
|
||||
"settings_tags" = "Tags";
|
||||
"settings_manage_tags" = "Manage tags";
|
||||
"settings_language" = "Language";
|
||||
"settings_premium" = "Premium";
|
||||
"settings_premium_status" = "Status";
|
||||
"settings_remove_ads" = "Remove ads";
|
||||
"settings_about" = "About";
|
||||
"settings_website" = "Website";
|
||||
"settings_support" = "Support email";
|
||||
"settings_rate_app" = "Rate MealMood";
|
||||
"settings_danger_zone" = "Danger zone";
|
||||
"settings_reset_all_data" = "Reset all data";
|
||||
"settings_reset_all_data_title" = "Reset all app data?";
|
||||
"settings_reset_all_data_message" = "This will delete dishes, tags, plans and settings, then start onboarding again.";
|
||||
"settings_reset_all_data_confirm" = "Reset all";
|
||||
|
||||
/* Premium */
|
||||
"premium_title" = "MealMood Premium";
|
||||
"premium_subtitle" = "Enjoy all features";
|
||||
"premium_no_ads" = "No ads";
|
||||
"premium_unlimited_dishes" = "Unlimited dishes";
|
||||
"premium_custom_tags" = "Custom tags";
|
||||
"premium_advanced_rules" = "Advanced tag rules";
|
||||
"premium_future_weeks" = "Plan unlimited future weeks";
|
||||
"premium_share_week" = "Weekly image export";
|
||||
"premium_family_sharing" = "Family Sharing support";
|
||||
"premium_plans" = "Available plans:";
|
||||
"premium_monthly" = "Monthly";
|
||||
"premium_yearly" = "Yearly";
|
||||
"premium_save" = "Save 44%";
|
||||
"premium_month" = "month";
|
||||
"premium_price_note" = "2.99 EUR per month";
|
||||
"premium_terms_note" = "Auto-renewing subscription. Cancel anytime from your Apple account settings.";
|
||||
"premium_active" = "Premium is active";
|
||||
"premium_subscribe" = "Subscribe";
|
||||
"premium_restore" = "Restore purchases";
|
||||
"premium_processing" = "Processing...";
|
||||
"premium_loading_products" = "Loading purchase options...";
|
||||
"premium_loading_products_hint" = "If this takes too long, tap Retry.";
|
||||
"premium_loading_timeout" = "Loading timed out";
|
||||
"premium_loading_timeout_hint" = "StoreKit did not respond in time. Tap Retry.";
|
||||
"premium_products_not_found" = "No products available";
|
||||
"premium_products_not_found_hint" = "Check Product IDs in App Store Connect / StoreKit config.";
|
||||
"premium_loading_failed" = "Could not load products";
|
||||
"premium_loading_failed_hint" = "Please check your StoreKit setup and try again.";
|
||||
"premium_retry_products" = "Retry";
|
||||
"premium_purchase_pending" = "Purchase is pending approval.";
|
||||
"premium_purchase_cancelled" = "Purchase was cancelled.";
|
||||
"premium_purchase_failed" = "Purchase failed. Please try again.";
|
||||
"tag_selector_empty" = "No tags available yet";
|
||||
"premium_limit_dishes" = "Free plan limit reached: 20 dishes";
|
||||
"premium_limit_rules" = "Advanced tag rules are Premium";
|
||||
"premium_limit_future_weeks" = "Free plan allows planning up to next week";
|
||||
|
||||
/* Home extras */
|
||||
"rule_override_title" = "Rule conflict";
|
||||
"rule_override_message" = "This dish breaks one or more tag rules. Assign anyway?";
|
||||
"rule_override_confirm" = "Assign anyway";
|
||||
"share_week_button" = "Share week";
|
||||
"share_week_title" = "MealMood weekly plan";
|
||||
"share_week_callout_title" = "Your week is ready to export";
|
||||
"share_week_callout_subtitle" = "Create a beautiful image to share or print your meal plan.";
|
||||
"ads_placeholder" = "Ad";
|
||||
"ads_loading" = "Loading ad...";
|
||||
"ads_unavailable" = "Ad unavailable";
|
||||
"history_title" = "Monthly history";
|
||||
"history_month_empty" = "No planned weeks in this month";
|
||||
"history_week_complete" = "Week complete";
|
||||
"history_week_incomplete" = "Week in progress";
|
||||
|
||||
/* Onboarding suggestions */
|
||||
"first_dishes_suggestions" = "Quick suggestions";
|
||||
"first_dishes_suggestion_add" = "Add suggestion";
|
||||
"first_dishes_suggestion_added" = "Added";
|
||||
"first_dishes_no_tags" = "Default tags are loading, please retry in a moment";
|
||||
|
||||
/* Notifications */
|
||||
"notification_planning_title" = "Plan your next week";
|
||||
"notification_planning_body" = "Your new week starts tomorrow and it is still not planned.";
|
||||
|
||||
/* Meal Types */
|
||||
"lunch" = "Lunch";
|
||||
"dinner" = "Dinner";
|
||||
|
||||
/* Days */
|
||||
"day_mon" = "Mon";
|
||||
"day_tue" = "Tue";
|
||||
"day_wed" = "Wed";
|
||||
"day_thu" = "Thu";
|
||||
"day_fri" = "Fri";
|
||||
"day_sat" = "Sat";
|
||||
"day_sun" = "Sun";
|
||||
|
||||
/* Durations */
|
||||
"duration_30" = "30 min";
|
||||
"duration_60" = "1 hour";
|
||||
"duration_90" = "1.5 hours";
|
||||
"duration_120" = "2 hours";
|
||||
|
||||
/* Reminders */
|
||||
"reminder_30" = "30 min before";
|
||||
"reminder_60" = "1 hour before";
|
||||
"reminder_120" = "2 hours before";
|
||||
@@ -0,0 +1,244 @@
|
||||
/* Onboarding */
|
||||
"onboarding_welcome_title" = "MealMood";
|
||||
"onboarding_welcome_subtitle" = "Planifica tu semana, disfruta tus comidas";
|
||||
"onboarding_welcome_benefit1" = "Sin estrés";
|
||||
"onboarding_welcome_benefit2" = "Sin contar calorías";
|
||||
"onboarding_welcome_benefit3" = "Sincroniza con tu calendario";
|
||||
"onboarding_start" = "Comenzar";
|
||||
"onboarding_continue" = "Continuar";
|
||||
"onboarding_skip" = "Omitir";
|
||||
"onboarding_finish" = "Finalizar";
|
||||
|
||||
/* Meal Windows */
|
||||
"meal_windows_title" = "¿Qué quieres planificar?";
|
||||
"meal_windows_dinner_only" = "Solo cenas";
|
||||
"meal_windows_lunch_only" = "Solo comidas";
|
||||
"meal_windows_both" = "Comidas y cenas";
|
||||
|
||||
/* Weekends */
|
||||
"weekends_title" = "¿Incluir fines de semana?";
|
||||
"weekends_toggle" = "Planificar también sábados y domingos";
|
||||
"weekends_note" = "Puedes cambiar esto después en ajustes";
|
||||
|
||||
/* Calendar */
|
||||
"calendar_title" = "¿Sincronizar con tu calendario?";
|
||||
"calendar_sync" = "Sincronizar";
|
||||
"calendar_select" = "Calendario";
|
||||
"calendar_lunch_time" = "Hora de comidas";
|
||||
"calendar_dinner_time" = "Hora de cenas";
|
||||
"calendar_optional" = "Opcional, puedes activarlo después";
|
||||
"calendar_permission_title" = "Acceso al calendario";
|
||||
"calendar_permission_message" = "MealMood necesita acceso a tu calendario para sincronizar eventos.";
|
||||
"calendar_permission_settings" = "Ir a Ajustes";
|
||||
|
||||
/* First Dishes */
|
||||
"first_dishes_title" = "Añade tus primeros platos";
|
||||
"first_dishes_subtitle" = "Añade al menos 2 platos para empezar";
|
||||
"first_dishes_name_placeholder" = "Nombre del plato";
|
||||
"first_dishes_add_tag" = "Añadir etiqueta";
|
||||
"first_dishes_add_dish" = "Añadir plato";
|
||||
"first_dishes_added" = "Platos añadidos:";
|
||||
|
||||
/* Home */
|
||||
"home_title" = "MealMood";
|
||||
"home_previous" = "Anterior";
|
||||
"home_next" = "Siguiente";
|
||||
"home_complete" = "Completar";
|
||||
"home_undo_last_action" = "Deshacer última acción";
|
||||
"home_reset" = "Resetear";
|
||||
"home_copy_previous_week" = "Copiar semana anterior";
|
||||
"home_my_dishes" = "Mis platos";
|
||||
"home_add_first_dish" = "Añade tu primer plato para empezar";
|
||||
"home_add_dish" = "Añadir plato";
|
||||
"home_my_dishes_search" = "Buscar platos";
|
||||
"home_my_dishes_search_empty" = "No se han encontrado platos";
|
||||
"home_my_dishes_hide_used" = "Ocultar usados esta semana";
|
||||
"home_pick_dish_title" = "Elige un plato";
|
||||
"home_pick_dish_search" = "Buscar platos";
|
||||
"home_pick_dish_empty" = "No hay platos que coincidan";
|
||||
"home_pick_dish_add_new" = "Añadir plato nuevo";
|
||||
"home_pick_dish_no_dishes" = "Todavía no hay platos. Añade uno para empezar.";
|
||||
"home_select_week" = "Seleccionar semana";
|
||||
"home_week_picker_title" = "Seleccionar semana";
|
||||
"home_week_picker_date" = "Fecha";
|
||||
"home_week_picker_go" = "Ir";
|
||||
|
||||
/* Reset Alert */
|
||||
"reset_title" = "¿Limpiar la semana completa?";
|
||||
"reset_message" = "Esto no se puede deshacer";
|
||||
"reset_cancel" = "Cancelar";
|
||||
"reset_confirm" = "Limpiar";
|
||||
|
||||
/* Toasts */
|
||||
"toast_dish_assigned" = "Plato asignado ✓";
|
||||
"toast_week_complete" = "¡Semana completa! 🎉";
|
||||
"toast_week_reset" = "Semana limpiada 🔄";
|
||||
"toast_dish_saved" = "Plato guardado ✓";
|
||||
"toast_event_deleted" = "Evento eliminado del calendario";
|
||||
"toast_cannot_complete" = "No se pudieron completar todos los slots";
|
||||
"toast_dish_deleted" = "Plato eliminado";
|
||||
"toast_no_free_slots" = "No hay huecos libres disponibles";
|
||||
"toast_calendar_synced" = "Calendario sincronizado ✓";
|
||||
"toast_copied_previous_week" = "Semana anterior copiada ✓";
|
||||
"toast_previous_week_empty" = "No hay semana anterior planificada";
|
||||
"toast_undo_applied" = "Última acción deshecha";
|
||||
"copy_previous_confirm_title" = "¿Reemplazar la semana actual?";
|
||||
"copy_previous_confirm_message" = "Ya tienes platos asignados. Copiar la semana anterior sobrescribirá las asignaciones actuales.";
|
||||
"copy_previous_confirm_confirm" = "Reemplazar semana";
|
||||
|
||||
/* Dish Form */
|
||||
"dish_new_title" = "Nuevo plato";
|
||||
"dish_edit_title" = "Editar plato";
|
||||
"dish_name_label" = "Nombre del plato *";
|
||||
"dish_name_placeholder" = "Ej: Pollo asado";
|
||||
"dish_description_label" = "Descripción (opcional)";
|
||||
"dish_description_placeholder" = "Ej: Jugoso y al horno con hierbas";
|
||||
"dish_tags_label" = "Etiquetas (opcional)";
|
||||
"dish_add_tag" = "Añadir etiqueta";
|
||||
"dish_delete" = "Eliminar plato";
|
||||
"dish_delete_title" = "¿Eliminar este plato?";
|
||||
"dish_delete_message" = "Esta acción no se puede deshacer";
|
||||
"dish_cancel" = "Cancelar";
|
||||
"dish_delete_blocked_title" = "No se puede eliminar";
|
||||
"dish_delete_blocked_message" = "No puedes eliminar un plato asignado en la semana actual.";
|
||||
"dish_delete_blocked_ok" = "Entendido";
|
||||
"dish_list_empty" = "No tienes platos aún";
|
||||
|
||||
/* Tag Selector */
|
||||
"tag_selector_title" = "Selecciona etiquetas";
|
||||
"tag_selector_done" = "Listo";
|
||||
|
||||
/* Tags */
|
||||
"tags_title" = "Etiquetas";
|
||||
"tags_max_per_week" = "Máximo por semana";
|
||||
"tags_no_consecutive" = "No consecutivo";
|
||||
"tags_no_consecutive_desc" = "Evita que aparezca días seguidos";
|
||||
"tags_no_duplicate" = "No repetir en el mismo día";
|
||||
"tags_no_duplicate_desc" = "No puede estar en comida y cena el mismo día";
|
||||
"tags_restriction" = "Restricción de horario";
|
||||
"tags_no_restriction" = "Sin restricción";
|
||||
"tags_lunch_only" = "Solo comida";
|
||||
"tags_dinner_only" = "Solo cena";
|
||||
"tags_no_limit" = "Sin límite";
|
||||
|
||||
/* Settings */
|
||||
"settings_title" = "Ajustes";
|
||||
"settings_planning" = "Planificación";
|
||||
"settings_meal_windows" = "Ventanas de comida";
|
||||
"settings_include_weekends" = "Incluir fines de semana";
|
||||
"settings_calendar" = "Calendario";
|
||||
"settings_icloud_sync" = "Sincronizar datos con iCloud";
|
||||
"settings_sync" = "Sincronizar";
|
||||
"settings_sync_mode" = "Modo de sincronización";
|
||||
"settings_sync_mode_week_complete" = "Auto (al completar semana)";
|
||||
"settings_sync_mode_manual" = "Manual (botón sincronizar)";
|
||||
"settings_sync_now" = "Sincronizar ahora";
|
||||
"settings_lunch_time" = "Hora de comidas";
|
||||
"settings_dinner_time" = "Hora de cenas";
|
||||
"settings_event_duration" = "Duración del evento";
|
||||
"settings_event_prefix" = "Prefijo del título";
|
||||
"settings_reminder" = "Recordatorio";
|
||||
"settings_reminder_none" = "Sin recordatorio";
|
||||
"settings_tags" = "Etiquetas";
|
||||
"settings_manage_tags" = "Gestionar etiquetas";
|
||||
"settings_language" = "Idioma";
|
||||
"settings_premium" = "Premium";
|
||||
"settings_premium_status" = "Estado";
|
||||
"settings_remove_ads" = "Quitar publicidad";
|
||||
"settings_about" = "Información";
|
||||
"settings_website" = "Web";
|
||||
"settings_support" = "Correo de soporte";
|
||||
"settings_rate_app" = "Valorar MealMood";
|
||||
"settings_danger_zone" = "Zona de peligro";
|
||||
"settings_reset_all_data" = "Resetear todos los datos";
|
||||
"settings_reset_all_data_title" = "¿Resetear todos los datos de la app?";
|
||||
"settings_reset_all_data_message" = "Se eliminarán platos, etiquetas, semanas y ajustes, y se iniciará de nuevo el onboarding.";
|
||||
"settings_reset_all_data_confirm" = "Resetear todo";
|
||||
|
||||
/* Premium */
|
||||
"premium_title" = "MealMood Premium";
|
||||
"premium_subtitle" = "Disfruta de todas las funciones";
|
||||
"premium_no_ads" = "Sin publicidad";
|
||||
"premium_unlimited_dishes" = "Platos ilimitados";
|
||||
"premium_custom_tags" = "Etiquetas personalizadas";
|
||||
"premium_advanced_rules" = "Reglas avanzadas de etiquetas";
|
||||
"premium_future_weeks" = "Planifica semanas futuras ilimitadas";
|
||||
"premium_share_week" = "Exportación semanal en imagen";
|
||||
"premium_family_sharing" = "Compatible con En Familia";
|
||||
"premium_plans" = "Planes disponibles:";
|
||||
"premium_monthly" = "Mensual";
|
||||
"premium_yearly" = "Anual";
|
||||
"premium_save" = "Ahorra 44%";
|
||||
"premium_month" = "mes";
|
||||
"premium_price_note" = "2,99 EUR al mes";
|
||||
"premium_terms_note" = "Suscripción renovable automáticamente. Cancela cuando quieras desde los ajustes de Apple.";
|
||||
"premium_active" = "Premium activo";
|
||||
"premium_subscribe" = "Suscribirse";
|
||||
"premium_restore" = "Restaurar compras";
|
||||
"premium_processing" = "Procesando...";
|
||||
"premium_loading_products" = "Cargando opciones de compra...";
|
||||
"premium_loading_products_hint" = "Si tarda demasiado, pulsa Reintentar.";
|
||||
"premium_loading_timeout" = "Tiempo de carga agotado";
|
||||
"premium_loading_timeout_hint" = "StoreKit no respondió a tiempo. Pulsa Reintentar.";
|
||||
"premium_products_not_found" = "No hay productos disponibles";
|
||||
"premium_products_not_found_hint" = "Revisa los Product IDs en App Store Connect / StoreKit config.";
|
||||
"premium_loading_failed" = "No se han podido cargar los productos";
|
||||
"premium_loading_failed_hint" = "Revisa la configuración de StoreKit e inténtalo de nuevo.";
|
||||
"premium_retry_products" = "Reintentar";
|
||||
"premium_purchase_pending" = "La compra está pendiente de aprobación.";
|
||||
"premium_purchase_cancelled" = "La compra se ha cancelado.";
|
||||
"premium_purchase_failed" = "La compra ha fallado. Inténtalo de nuevo.";
|
||||
"tag_selector_empty" = "Todavía no hay etiquetas";
|
||||
"premium_limit_dishes" = "Límite del plan gratuito: 20 platos";
|
||||
"premium_limit_rules" = "Las reglas avanzadas son Premium";
|
||||
"premium_limit_future_weeks" = "El plan gratuito permite planificar hasta la semana siguiente";
|
||||
|
||||
/* Home extras */
|
||||
"rule_override_title" = "Incumple reglas";
|
||||
"rule_override_message" = "Este plato incumple una o más reglas de etiquetas. ¿Quieres asignarlo igualmente?";
|
||||
"rule_override_confirm" = "Asignar igualmente";
|
||||
"share_week_button" = "Compartir semana";
|
||||
"share_week_title" = "Plan semanal MealMood";
|
||||
"share_week_callout_title" = "Tu semana está lista para exportar";
|
||||
"share_week_callout_subtitle" = "Genera una imagen bonita para compartir o imprimir tu planificación.";
|
||||
"ads_placeholder" = "Publicidad";
|
||||
"ads_loading" = "Cargando anuncio...";
|
||||
"ads_unavailable" = "Anuncio no disponible";
|
||||
"history_title" = "Histórico mensual";
|
||||
"history_month_empty" = "No hay semanas planificadas en este mes";
|
||||
"history_week_complete" = "Semana completa";
|
||||
"history_week_incomplete" = "Semana en progreso";
|
||||
|
||||
/* Onboarding suggestions */
|
||||
"first_dishes_suggestions" = "Sugerencias rápidas";
|
||||
"first_dishes_suggestion_add" = "Añadir sugerencia";
|
||||
"first_dishes_suggestion_added" = "Añadido";
|
||||
"first_dishes_no_tags" = "Las etiquetas por defecto se están cargando, inténtalo de nuevo en un momento";
|
||||
|
||||
/* Notifications */
|
||||
"notification_planning_title" = "Planifica tu próxima semana";
|
||||
"notification_planning_body" = "Mañana empieza la semana y aún no está planificada.";
|
||||
|
||||
/* Meal Types */
|
||||
"lunch" = "Comida";
|
||||
"dinner" = "Cena";
|
||||
|
||||
/* Days */
|
||||
"day_mon" = "Lun";
|
||||
"day_tue" = "Mar";
|
||||
"day_wed" = "Mié";
|
||||
"day_thu" = "Jue";
|
||||
"day_fri" = "Vie";
|
||||
"day_sat" = "Sáb";
|
||||
"day_sun" = "Dom";
|
||||
|
||||
/* Durations */
|
||||
"duration_30" = "30 min";
|
||||
"duration_60" = "1 hora";
|
||||
"duration_90" = "1.5 horas";
|
||||
"duration_120" = "2 horas";
|
||||
|
||||
/* Reminders */
|
||||
"reminder_30" = "30 min antes";
|
||||
"reminder_60" = "1 hora antes";
|
||||
"reminder_120" = "2 horas antes";
|
||||
@@ -0,0 +1,14 @@
|
||||
enum AdMobConfig {
|
||||
static let appId = "ca-app-pub-1549720748100858~9985112590"
|
||||
static let bannerHomeUnitId = "ca-app-pub-1549720748100858/7693991173"
|
||||
static let testBannerUnitId = "ca-app-pub-3940256099942544/2435281174"
|
||||
|
||||
static var resolvedBannerHomeUnitId: String {
|
||||
#if DEBUG
|
||||
// Always use Google's official test unit while debugging (simulator and device).
|
||||
return testBannerUnitId
|
||||
#else
|
||||
return bannerHomeUnitId
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import Foundation
|
||||
|
||||
struct AutocompleteEngine {
|
||||
|
||||
struct AutocompleteResult {
|
||||
let filledSlots: [(slotId: UUID, dishId: UUID)]
|
||||
let unfilledCount: Int
|
||||
}
|
||||
|
||||
static func autocomplete(
|
||||
emptySlots: [MealSlot],
|
||||
currentPlan: WeekPlan,
|
||||
allDishes: [Dish],
|
||||
allTags: [Tag]
|
||||
) -> AutocompleteResult {
|
||||
let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) })
|
||||
let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) })
|
||||
var filledSlots: [(slotId: UUID, dishId: UUID)] = []
|
||||
var unfilledCount = 0
|
||||
|
||||
let sortedEmpty = emptySlots.sorted {
|
||||
($0.dayOfWeek, $0.mealType) < ($1.dayOfWeek, $1.mealType)
|
||||
}
|
||||
|
||||
for slot in sortedEmpty {
|
||||
let strictCandidates = allDishes.filter { dish in
|
||||
!violatesRules(
|
||||
dish: dish,
|
||||
slot: slot,
|
||||
plan: currentPlan,
|
||||
tagMap: tagMap,
|
||||
dishMap: dishMap
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback: if strict rules cannot fill a slot, allow repeating dishes
|
||||
// that only fail the implicit "no repeat this week" rule.
|
||||
let candidates: [Dish]
|
||||
if strictCandidates.isEmpty {
|
||||
candidates = allDishes.filter { dish in
|
||||
!violatesExplicitRules(
|
||||
dish: dish,
|
||||
slot: slot,
|
||||
plan: currentPlan,
|
||||
tagMap: tagMap,
|
||||
dishMap: dishMap
|
||||
)
|
||||
}
|
||||
} else {
|
||||
candidates = strictCandidates
|
||||
}
|
||||
|
||||
if candidates.isEmpty {
|
||||
unfilledCount += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if let picked = weightedRandomPick(candidates: candidates, plan: currentPlan) {
|
||||
slot.dishId = picked.id
|
||||
filledSlots.append((slotId: slot.id, dishId: picked.id))
|
||||
} else {
|
||||
unfilledCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
return AutocompleteResult(filledSlots: filledSlots, unfilledCount: unfilledCount)
|
||||
}
|
||||
|
||||
static func validateDrop(
|
||||
dish: Dish,
|
||||
slot: MealSlot,
|
||||
plan: WeekPlan,
|
||||
allTags: [Tag],
|
||||
allDishes: [Dish]
|
||||
) -> Bool {
|
||||
let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) })
|
||||
let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) })
|
||||
return !violatesRules(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap)
|
||||
}
|
||||
|
||||
private static func violatesRules(
|
||||
dish: Dish,
|
||||
slot: MealSlot,
|
||||
plan: WeekPlan,
|
||||
tagMap: [UUID: Tag],
|
||||
dishMap: [UUID: Dish]
|
||||
) -> Bool {
|
||||
violatesDefaultNoRepeatRule(dish: dish, slot: slot, plan: plan, tagMap: tagMap) ||
|
||||
violatesExplicitRules(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap)
|
||||
}
|
||||
|
||||
private static func violatesDefaultNoRepeatRule(
|
||||
dish: Dish,
|
||||
slot: MealSlot,
|
||||
plan: WeekPlan,
|
||||
tagMap: [UUID: Tag]
|
||||
) -> Bool {
|
||||
// Default behavior: if a dish has no explicit rules, avoid repeating it in the same week.
|
||||
if !dishHasExplicitRules(dish: dish, tagMap: tagMap) {
|
||||
let alreadyAssignedThisWeek = plan.slots.contains {
|
||||
$0.id != slot.id && $0.dishId == dish.id
|
||||
}
|
||||
if alreadyAssignedThisWeek { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func violatesExplicitRules(
|
||||
dish: Dish,
|
||||
slot: MealSlot,
|
||||
plan: WeekPlan,
|
||||
tagMap: [UUID: Tag],
|
||||
dishMap: [UUID: Dish]
|
||||
) -> Bool {
|
||||
for tagId in dish.tagIds {
|
||||
guard let tag = tagMap[tagId] else { continue }
|
||||
|
||||
// Max per week
|
||||
if let maxPerWeek = tag.maxPerWeek {
|
||||
var count = 0
|
||||
for s in plan.slots {
|
||||
guard let did = s.dishId, let d = dishMap[did] else { continue }
|
||||
if d.tagIds.contains(tagId) { count += 1 }
|
||||
}
|
||||
if count >= maxPerWeek { return true }
|
||||
}
|
||||
|
||||
// No consecutive
|
||||
if tag.noConsecutive {
|
||||
for adjSlot in adjacentSlots(of: slot, in: plan) {
|
||||
guard let did = adjSlot.dishId, let d = dishMap[did] else { continue }
|
||||
if d.tagIds.contains(tagId) { return true }
|
||||
}
|
||||
}
|
||||
|
||||
// No duplicate in day
|
||||
if tag.noDuplicateInDay {
|
||||
for sdSlot in plan.slots where sdSlot.dayOfWeek == slot.dayOfWeek && sdSlot.id != slot.id {
|
||||
guard let did = sdSlot.dishId, let d = dishMap[did] else { continue }
|
||||
if d.tagIds.contains(tagId) { return true }
|
||||
}
|
||||
}
|
||||
|
||||
// Meal type restriction
|
||||
if let restriction = tag.mealTypeRestriction, slot.mealType != restriction {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func dishHasExplicitRules(dish: Dish, tagMap: [UUID: Tag]) -> Bool {
|
||||
for tagId in dish.tagIds {
|
||||
guard let tag = tagMap[tagId] else { continue }
|
||||
if tag.maxPerWeek != nil ||
|
||||
tag.noConsecutive ||
|
||||
tag.noDuplicateInDay ||
|
||||
tag.mealTypeRestriction != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func adjacentSlots(of slot: MealSlot, in plan: WeekPlan) -> [MealSlot] {
|
||||
var result: [MealSlot] = []
|
||||
if slot.dayOfWeek > 0,
|
||||
let prev = plan.slots.first(where: { $0.dayOfWeek == slot.dayOfWeek - 1 && $0.mealType == slot.mealType }) {
|
||||
result.append(prev)
|
||||
}
|
||||
if slot.dayOfWeek < 6,
|
||||
let next = plan.slots.first(where: { $0.dayOfWeek == slot.dayOfWeek + 1 && $0.mealType == slot.mealType }) {
|
||||
result.append(next)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func weightedRandomPick(candidates: [Dish], plan: WeekPlan) -> Dish? {
|
||||
guard !candidates.isEmpty else { return nil }
|
||||
let weights: [Double] = candidates.map { dish in
|
||||
let usage = plan.slots.filter { $0.dishId == dish.id }.count
|
||||
switch usage {
|
||||
case 0: return 3.0
|
||||
case 1: return 2.0
|
||||
default: return 1.0
|
||||
}
|
||||
}
|
||||
let total = weights.reduce(0, +)
|
||||
var r = Double.random(in: 0..<total)
|
||||
for (i, w) in weights.enumerated() {
|
||||
r -= w
|
||||
if r <= 0 { return candidates[i] }
|
||||
}
|
||||
return candidates.last
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import EventKit
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class CalendarService {
|
||||
static let shared = CalendarService()
|
||||
private let eventStore = EKEventStore()
|
||||
|
||||
private init() {}
|
||||
|
||||
func requestAccess() async -> Bool {
|
||||
do {
|
||||
return try await eventStore.requestFullAccessToEvents()
|
||||
} catch {
|
||||
print("Calendar access error: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func availableCalendars() -> [EKCalendar] {
|
||||
eventStore.calendars(for: .event)
|
||||
}
|
||||
|
||||
func createEvent(
|
||||
slot: MealSlot,
|
||||
dishName: String,
|
||||
dishDescription: String?,
|
||||
weekStartDate: Date,
|
||||
settings: AppSettings
|
||||
) -> String? {
|
||||
guard let calendarId = settings.calendarId,
|
||||
let calendar = eventStore.calendar(withIdentifier: calendarId) else { return nil }
|
||||
|
||||
let event = EKEvent(eventStore: eventStore)
|
||||
|
||||
let prefix = settings.eventPrefix.isEmpty ? "" : "\(settings.eventPrefix) "
|
||||
let mealNameKey = slot.mealType == "lunch" ? "lunch" : "dinner"
|
||||
let mealName = localizedString(mealNameKey, language: settings.languageEnum.resolved())
|
||||
event.title = "\(prefix)\(mealName): \(dishName)"
|
||||
|
||||
let slotDate = weekStartDate.addingDays(slot.dayOfWeek)
|
||||
let slotTime = slot.mealType == "lunch" ? settings.lunchTime : settings.dinnerTime
|
||||
event.startDate = combineDateAndTime(date: slotDate, time: slotTime)
|
||||
event.endDate = event.startDate.addingMinutes(settings.eventDuration)
|
||||
event.notes = dishDescription
|
||||
event.calendar = calendar
|
||||
|
||||
if let reminder = settings.reminderMinutesBefore {
|
||||
event.addAlarm(EKAlarm(relativeOffset: -TimeInterval(reminder * 60)))
|
||||
}
|
||||
|
||||
do {
|
||||
try eventStore.save(event, span: .thisEvent)
|
||||
return event.eventIdentifier
|
||||
} catch {
|
||||
print("Error saving event: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func deleteEvent(eventId: String) {
|
||||
guard let event = eventStore.event(withIdentifier: eventId) else { return }
|
||||
do {
|
||||
try eventStore.remove(event, span: .thisEvent)
|
||||
} catch {
|
||||
print("Error deleting event: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func updateEventsTime(slots: [MealSlot], weekStartDate: Date, settings: AppSettings) {
|
||||
for slot in slots {
|
||||
guard let eventId = slot.calendarEventId,
|
||||
let event = eventStore.event(withIdentifier: eventId),
|
||||
event.startDate >= Date() else { continue }
|
||||
|
||||
let slotDate = weekStartDate.addingDays(slot.dayOfWeek)
|
||||
let newTime = slot.mealType == "lunch" ? settings.lunchTime : settings.dinnerTime
|
||||
event.startDate = combineDateAndTime(date: slotDate, time: newTime)
|
||||
event.endDate = event.startDate.addingMinutes(settings.eventDuration)
|
||||
|
||||
try? eventStore.save(event, span: .thisEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
struct DefaultDataService {
|
||||
|
||||
static func createDefaultTags(context: ModelContext) {
|
||||
let defaults: [(name: String, nameEN: String, color: String, maxPerWeek: Int?, noConsecutive: Bool, noDuplicateInDay: Bool, mealTypeRestriction: String?, sortOrder: Int)] = [
|
||||
("Carne", "Meat", "#E74C3C", 3, true, true, nil, 0),
|
||||
("Pescado", "Fish", "#3498DB", 2, true, true, nil, 1),
|
||||
("Legumbres", "Legumes", "#95A5A6", 2, false, true, nil, 2),
|
||||
("Verduras", "Vegetables", "#2ECC71", nil, false, false, nil, 3),
|
||||
("Huevos", "Eggs", "#F1C40F", 2, false, true, nil, 4),
|
||||
("Pasta/Arroz", "Pasta/Rice", "#D4AC6E", 3, true, false, nil, 5),
|
||||
("Solo Cena", "Dinner Only", "#9B59B6", nil, false, false, "dinner", 6),
|
||||
("Solo Comida", "Lunch Only", "#E67E22", nil, false, false, "lunch", 7),
|
||||
("Con Gluten", "Contains Gluten", "#8B4513", nil, false, false, nil, 8),
|
||||
("Sin Gluten", "Gluten Free", "#1ABC9C", nil, false, false, nil, 9)
|
||||
]
|
||||
|
||||
for d in defaults {
|
||||
let tag = Tag(
|
||||
name: d.name,
|
||||
nameEN: d.nameEN,
|
||||
color: d.color,
|
||||
maxPerWeek: d.maxPerWeek,
|
||||
noConsecutive: d.noConsecutive,
|
||||
noDuplicateInDay: d.noDuplicateInDay,
|
||||
mealTypeRestriction: d.mealTypeRestriction,
|
||||
isDefault: true,
|
||||
sortOrder: d.sortOrder
|
||||
)
|
||||
context.insert(tag)
|
||||
}
|
||||
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
static func createDefaultSettings(context: ModelContext) {
|
||||
let settings = AppSettings()
|
||||
context.insert(settings)
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
static func createWeekPlan(for weekStart: Date, settings: AppSettings, context: ModelContext) -> WeekPlan {
|
||||
let plan = WeekPlan(weekStartDate: weekStart)
|
||||
context.insert(plan)
|
||||
|
||||
let maxDay = settings.includeWeekends ? 6 : 4
|
||||
let mealTypes: [String] = {
|
||||
switch settings.mealWindowsEnum {
|
||||
case .dinnerOnly: return ["dinner"]
|
||||
case .lunchOnly: return ["lunch"]
|
||||
case .both: return ["lunch", "dinner"]
|
||||
}
|
||||
}()
|
||||
|
||||
for day in 0...maxDay {
|
||||
for mealType in mealTypes {
|
||||
let slot = MealSlot(dayOfWeek: day, mealType: mealType)
|
||||
slot.weekPlan = plan
|
||||
plan.slots.append(slot)
|
||||
context.insert(slot)
|
||||
}
|
||||
}
|
||||
|
||||
try? context.save()
|
||||
return plan
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class HapticManager {
|
||||
static let shared = HapticManager()
|
||||
private init() {}
|
||||
|
||||
func impact(style: UIImpactFeedbackGenerator.FeedbackStyle) {
|
||||
let generator = UIImpactFeedbackGenerator(style: style)
|
||||
generator.prepare()
|
||||
generator.impactOccurred()
|
||||
}
|
||||
|
||||
func notification(type: UINotificationFeedbackGenerator.FeedbackType) {
|
||||
let generator = UINotificationFeedbackGenerator()
|
||||
generator.prepare()
|
||||
generator.notificationOccurred(type)
|
||||
}
|
||||
|
||||
func selection() {
|
||||
let generator = UISelectionFeedbackGenerator()
|
||||
generator.prepare()
|
||||
generator.selectionChanged()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
final class ICloudSyncService {
|
||||
static let shared = ICloudSyncService()
|
||||
|
||||
private let store = NSUbiquitousKeyValueStore.default
|
||||
private let payloadKey = "mealmood_sync_payload_v1"
|
||||
private let lastAppliedKey = "mealmood_sync_last_applied"
|
||||
|
||||
private var isApplyingRemote = false
|
||||
|
||||
private init() {}
|
||||
|
||||
func pullRemoteIfNeeded(context: ModelContext) async {
|
||||
store.synchronize()
|
||||
|
||||
guard let data = store.data(forKey: payloadKey),
|
||||
let snapshot = try? JSONDecoder().decode(SyncSnapshot.self, from: data) else {
|
||||
return
|
||||
}
|
||||
|
||||
let lastApplied = UserDefaults.standard.double(forKey: lastAppliedKey)
|
||||
let remoteTs = snapshot.updatedAt.timeIntervalSince1970
|
||||
if remoteTs <= lastApplied {
|
||||
return
|
||||
}
|
||||
|
||||
isApplyingRemote = true
|
||||
defer { isApplyingRemote = false }
|
||||
|
||||
apply(snapshot: snapshot, context: context)
|
||||
UserDefaults.standard.set(remoteTs, forKey: lastAppliedKey)
|
||||
}
|
||||
|
||||
func pushLocalSnapshot(context: ModelContext) async {
|
||||
if isApplyingRemote { return }
|
||||
guard let snapshot = makeSnapshot(context: context) else { return }
|
||||
guard let data = try? JSONEncoder().encode(snapshot) else { return }
|
||||
|
||||
store.set(data, forKey: payloadKey)
|
||||
store.synchronize()
|
||||
UserDefaults.standard.set(snapshot.updatedAt.timeIntervalSince1970, forKey: lastAppliedKey)
|
||||
}
|
||||
|
||||
private func makeSnapshot(context: ModelContext) -> SyncSnapshot? {
|
||||
let settings = (try? context.fetch(FetchDescriptor<AppSettings>()))?.first
|
||||
let tags = (try? context.fetch(FetchDescriptor<Tag>())) ?? []
|
||||
let dishes = (try? context.fetch(FetchDescriptor<Dish>())) ?? []
|
||||
let plans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
|
||||
|
||||
guard settings != nil || !tags.isEmpty || !dishes.isEmpty || !plans.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let snapshot = SyncSnapshot(
|
||||
updatedAt: Date(),
|
||||
settings: settings.map {
|
||||
SettingsPayload(
|
||||
mealWindows: $0.mealWindows,
|
||||
includeWeekends: $0.includeWeekends,
|
||||
language: $0.language,
|
||||
calendarId: $0.calendarId,
|
||||
syncEnabled: $0.syncEnabled,
|
||||
syncMode: $0.syncMode,
|
||||
lunchTime: $0.lunchTime,
|
||||
dinnerTime: $0.dinnerTime,
|
||||
eventDuration: $0.eventDuration,
|
||||
eventPrefix: $0.eventPrefix,
|
||||
reminderMinutesBefore: $0.reminderMinutesBefore,
|
||||
iCloudSyncEnabled: $0.iCloudSyncEnabled,
|
||||
isPremium: $0.isPremium,
|
||||
onboardingCompleted: $0.onboardingCompleted
|
||||
)
|
||||
},
|
||||
tags: tags.map {
|
||||
TagPayload(
|
||||
id: $0.id,
|
||||
name: $0.name,
|
||||
nameEN: $0.nameEN,
|
||||
color: $0.color,
|
||||
maxPerWeek: $0.maxPerWeek,
|
||||
noConsecutive: $0.noConsecutive,
|
||||
noDuplicateInDay: $0.noDuplicateInDay,
|
||||
mealTypeRestriction: $0.mealTypeRestriction,
|
||||
isDefault: $0.isDefault,
|
||||
sortOrder: $0.sortOrder
|
||||
)
|
||||
},
|
||||
dishes: dishes.map {
|
||||
DishPayload(
|
||||
id: $0.id,
|
||||
name: $0.name,
|
||||
descriptionText: $0.descriptionText,
|
||||
tagIds: $0.tagIds,
|
||||
createdAt: $0.createdAt
|
||||
)
|
||||
},
|
||||
weekPlans: plans.map { plan in
|
||||
WeekPlanPayload(
|
||||
id: plan.id,
|
||||
weekStartDate: plan.weekStartDate,
|
||||
createdAt: plan.createdAt,
|
||||
updatedAt: plan.updatedAt,
|
||||
slots: plan.slots.map {
|
||||
MealSlotPayload(
|
||||
id: $0.id,
|
||||
dayOfWeek: $0.dayOfWeek,
|
||||
mealType: $0.mealType,
|
||||
dishId: $0.dishId,
|
||||
calendarEventId: $0.calendarEventId,
|
||||
isRuleOverridden: $0.isRuleOverridden
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private func apply(snapshot: SyncSnapshot, context: ModelContext) {
|
||||
guard snapshot.settings != nil || !snapshot.tags.isEmpty || !snapshot.dishes.isEmpty || !snapshot.weekPlans.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let existingSlots = (try? context.fetch(FetchDescriptor<MealSlot>())) ?? []
|
||||
let existingPlans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
|
||||
let existingDishes = (try? context.fetch(FetchDescriptor<Dish>())) ?? []
|
||||
let existingTags = (try? context.fetch(FetchDescriptor<Tag>())) ?? []
|
||||
let existingSettings = (try? context.fetch(FetchDescriptor<AppSettings>())) ?? []
|
||||
|
||||
existingSlots.forEach { context.delete($0) }
|
||||
existingPlans.forEach { context.delete($0) }
|
||||
existingDishes.forEach { context.delete($0) }
|
||||
existingTags.forEach { context.delete($0) }
|
||||
existingSettings.forEach { context.delete($0) }
|
||||
|
||||
if let settingsPayload = snapshot.settings {
|
||||
let settings = AppSettings()
|
||||
settings.mealWindows = settingsPayload.mealWindows
|
||||
settings.includeWeekends = settingsPayload.includeWeekends
|
||||
settings.language = settingsPayload.language
|
||||
settings.calendarId = settingsPayload.calendarId
|
||||
settings.syncEnabled = settingsPayload.syncEnabled
|
||||
settings.syncMode = settingsPayload.syncMode
|
||||
settings.lunchTime = settingsPayload.lunchTime
|
||||
settings.dinnerTime = settingsPayload.dinnerTime
|
||||
settings.eventDuration = settingsPayload.eventDuration
|
||||
settings.eventPrefix = settingsPayload.eventPrefix
|
||||
settings.reminderMinutesBefore = settingsPayload.reminderMinutesBefore
|
||||
settings.iCloudSyncEnabled = settingsPayload.iCloudSyncEnabled
|
||||
settings.isPremium = settingsPayload.isPremium
|
||||
settings.onboardingCompleted = settingsPayload.onboardingCompleted
|
||||
context.insert(settings)
|
||||
}
|
||||
|
||||
snapshot.tags.forEach { payload in
|
||||
let tag = Tag(
|
||||
id: payload.id,
|
||||
name: payload.name,
|
||||
nameEN: payload.nameEN,
|
||||
color: payload.color,
|
||||
maxPerWeek: payload.maxPerWeek,
|
||||
noConsecutive: payload.noConsecutive,
|
||||
noDuplicateInDay: payload.noDuplicateInDay,
|
||||
mealTypeRestriction: payload.mealTypeRestriction,
|
||||
isDefault: payload.isDefault,
|
||||
sortOrder: payload.sortOrder
|
||||
)
|
||||
context.insert(tag)
|
||||
}
|
||||
|
||||
snapshot.dishes.forEach { payload in
|
||||
let dish = Dish(
|
||||
id: payload.id,
|
||||
name: payload.name,
|
||||
descriptionText: payload.descriptionText,
|
||||
tagIds: payload.tagIds,
|
||||
createdAt: payload.createdAt
|
||||
)
|
||||
context.insert(dish)
|
||||
}
|
||||
|
||||
snapshot.weekPlans.forEach { payload in
|
||||
let plan = WeekPlan(
|
||||
id: payload.id,
|
||||
weekStartDate: payload.weekStartDate,
|
||||
slots: [],
|
||||
createdAt: payload.createdAt,
|
||||
updatedAt: payload.updatedAt
|
||||
)
|
||||
context.insert(plan)
|
||||
|
||||
payload.slots.forEach { slotPayload in
|
||||
let slot = MealSlot(
|
||||
id: slotPayload.id,
|
||||
dayOfWeek: slotPayload.dayOfWeek,
|
||||
mealType: slotPayload.mealType,
|
||||
dishId: slotPayload.dishId,
|
||||
calendarEventId: slotPayload.calendarEventId,
|
||||
isRuleOverridden: slotPayload.isRuleOverridden
|
||||
)
|
||||
slot.weekPlan = plan
|
||||
plan.slots.append(slot)
|
||||
context.insert(slot)
|
||||
}
|
||||
}
|
||||
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
|
||||
private struct SyncSnapshot: Codable {
|
||||
let updatedAt: Date
|
||||
let settings: SettingsPayload?
|
||||
let tags: [TagPayload]
|
||||
let dishes: [DishPayload]
|
||||
let weekPlans: [WeekPlanPayload]
|
||||
}
|
||||
|
||||
private struct SettingsPayload: Codable {
|
||||
let mealWindows: String
|
||||
let includeWeekends: Bool
|
||||
let language: String
|
||||
let calendarId: String?
|
||||
let syncEnabled: Bool
|
||||
let syncMode: String?
|
||||
let lunchTime: Date
|
||||
let dinnerTime: Date
|
||||
let eventDuration: Int
|
||||
let eventPrefix: String
|
||||
let reminderMinutesBefore: Int?
|
||||
let iCloudSyncEnabled: Bool?
|
||||
let isPremium: Bool
|
||||
let onboardingCompleted: Bool
|
||||
}
|
||||
|
||||
private struct TagPayload: Codable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let nameEN: String
|
||||
let color: String
|
||||
let maxPerWeek: Int?
|
||||
let noConsecutive: Bool
|
||||
let noDuplicateInDay: Bool
|
||||
let mealTypeRestriction: String?
|
||||
let isDefault: Bool
|
||||
let sortOrder: Int
|
||||
}
|
||||
|
||||
private struct DishPayload: Codable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let descriptionText: String?
|
||||
let tagIds: [UUID]
|
||||
let createdAt: Date
|
||||
}
|
||||
|
||||
private struct WeekPlanPayload: Codable {
|
||||
let id: UUID
|
||||
let weekStartDate: Date
|
||||
let createdAt: Date
|
||||
let updatedAt: Date
|
||||
let slots: [MealSlotPayload]
|
||||
}
|
||||
|
||||
private struct MealSlotPayload: Codable {
|
||||
let id: UUID
|
||||
let dayOfWeek: Int
|
||||
let mealType: String
|
||||
let dishId: UUID?
|
||||
let calendarEventId: String?
|
||||
let isRuleOverridden: Bool
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
@MainActor
|
||||
final class NotificationService {
|
||||
static let shared = NotificationService()
|
||||
|
||||
private init() {}
|
||||
|
||||
func requestPermissionIfNeeded() async {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
let settings = await center.notificationSettings()
|
||||
guard settings.authorizationStatus == .notDetermined else { return }
|
||||
_ = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||
}
|
||||
|
||||
func schedulePlanningReminderIfNeeded(nextWeekPlan: WeekPlan?, language: AppLanguage) {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
let identifier = "mealmood.next-week-planning"
|
||||
|
||||
let isComplete = nextWeekPlan?.slots.allSatisfy { $0.dishId != nil } ?? false
|
||||
if isComplete {
|
||||
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
||||
return
|
||||
}
|
||||
|
||||
let nextWeekStart = Date().startOfWeek().addingDays(7)
|
||||
let reminderDay = nextWeekStart.addingDays(-1)
|
||||
let calendar = Calendar.current
|
||||
var components = calendar.dateComponents([.year, .month, .day], from: reminderDay)
|
||||
components.hour = 19
|
||||
components.minute = 0
|
||||
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = localizedString("notification_planning_title", language: language)
|
||||
content.body = localizedString("notification_planning_body", language: language)
|
||||
content.sound = .default
|
||||
|
||||
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
|
||||
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
||||
center.add(request)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
enum PremiumAccess {
|
||||
static let freeDishLimit = 20
|
||||
static let freeFutureWeeks = 1
|
||||
|
||||
static func hasReachedFreeDishLimit(dishCount: Int, isPremium: Bool) -> Bool {
|
||||
!isPremium && dishCount >= freeDishLimit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import UIKit
|
||||
import StoreKit
|
||||
|
||||
@MainActor
|
||||
final class ReviewPromptService {
|
||||
static let shared = ReviewPromptService()
|
||||
|
||||
private let milestones = [1, 2, 4, 8]
|
||||
private let minimumDaysBetweenPrompts: Double = 30
|
||||
private let promptedMilestoneKey = "review_prompted_milestone"
|
||||
private let lastPromptDateKey = "review_prompt_last_date"
|
||||
|
||||
private init() {}
|
||||
|
||||
func considerPromptAfterWeekCompletion(completedWeeks: Int) {
|
||||
guard let milestone = milestones.first(where: { completedWeeks >= $0 }) else { return }
|
||||
let alreadyPrompted = UserDefaults.standard.integer(forKey: promptedMilestoneKey)
|
||||
guard milestone > alreadyPrompted else { return }
|
||||
guard canPromptNow() else { return }
|
||||
|
||||
requestReview()
|
||||
UserDefaults.standard.set(milestone, forKey: promptedMilestoneKey)
|
||||
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: lastPromptDateKey)
|
||||
}
|
||||
|
||||
func requestFromSettings() {
|
||||
requestReview()
|
||||
}
|
||||
|
||||
private func canPromptNow() -> Bool {
|
||||
let lastPrompt = UserDefaults.standard.double(forKey: lastPromptDateKey)
|
||||
guard lastPrompt > 0 else { return true }
|
||||
return Date().timeIntervalSince1970 - lastPrompt >= minimumDaysBetweenPrompts * 24 * 60 * 60
|
||||
}
|
||||
|
||||
private func requestReview() {
|
||||
guard let scene = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene })
|
||||
.first(where: { $0.activationState == .foregroundActive }) else { return }
|
||||
|
||||
SKStoreReviewController.requestReview(in: scene)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import StoreKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class StoreManager: ObservableObject {
|
||||
enum ProductLoadState {
|
||||
case idle
|
||||
case loading
|
||||
case loaded
|
||||
case timedOut
|
||||
case notFound
|
||||
case failed
|
||||
}
|
||||
|
||||
enum PurchaseResult {
|
||||
case success
|
||||
case cancelled
|
||||
case pending
|
||||
case failed
|
||||
}
|
||||
|
||||
@Published var products: [Product] = []
|
||||
@Published var isPremium: Bool = false
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var isLoadingProducts: Bool = false
|
||||
@Published var productLoadState: ProductLoadState = .idle
|
||||
@Published var debugLoadedProductIds: [String] = []
|
||||
@Published var debugLoadedProducts: [String] = []
|
||||
|
||||
static let monthlyProductId = "com.mealmood.premium.monthly"
|
||||
static let monthlyProductIdAlt = "com.alexandrevazquez.mealmood.premium.monthly"
|
||||
static let monthlyProductIdLegacy = "com.alexandrev.mealmood.premium.monthly"
|
||||
static let monthlyProductIdShort = "com.alexandrevazquez.mealmood.premium"
|
||||
|
||||
private var productIds: [String] {
|
||||
var ids = [Self.monthlyProductId]
|
||||
|
||||
if let bundleId = Bundle.main.bundleIdentifier {
|
||||
ids.append("\(bundleId).premium.monthly")
|
||||
}
|
||||
|
||||
var seen = Set<String>()
|
||||
return ids.filter { seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
init() {
|
||||
Task {
|
||||
await loadProducts()
|
||||
await checkPremiumStatus()
|
||||
}
|
||||
}
|
||||
|
||||
func loadProducts() async {
|
||||
isLoadingProducts = true
|
||||
productLoadState = .loading
|
||||
defer { isLoadingProducts = false }
|
||||
do {
|
||||
let fetchedProducts = try await loadProductsWithRetries()
|
||||
products = fetchedProducts
|
||||
debugLoadedProductIds = fetchedProducts.map(\.id)
|
||||
debugLoadedProducts = fetchedProducts.map { "\($0.id) [\($0.type)]" }
|
||||
productLoadState = fetchedProducts.isEmpty ? .notFound : .loaded
|
||||
} catch {
|
||||
if error is TimeoutError {
|
||||
productLoadState = .timedOut
|
||||
} else {
|
||||
productLoadState = .failed
|
||||
}
|
||||
print("Failed to load products: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func purchase(_ product: Product) async -> PurchaseResult {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let result = try await product.purchase()
|
||||
switch result {
|
||||
case .success(let verification):
|
||||
if case .verified(let transaction) = verification {
|
||||
await transaction.finish()
|
||||
isPremium = true
|
||||
return .success
|
||||
}
|
||||
return .failed
|
||||
case .userCancelled:
|
||||
return .cancelled
|
||||
case .pending:
|
||||
return .pending
|
||||
@unknown default:
|
||||
return .failed
|
||||
}
|
||||
} catch {
|
||||
print("Purchase failed: \(error)")
|
||||
return .failed
|
||||
}
|
||||
}
|
||||
|
||||
func restorePurchases() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
try await AppStore.sync()
|
||||
await checkPremiumStatus()
|
||||
} catch {
|
||||
print("Restore failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func checkPremiumStatus() async {
|
||||
isPremium = await Self.hasActiveSubscription(productIds: productIds)
|
||||
}
|
||||
|
||||
var monthlyProduct: Product? {
|
||||
let prioritizedIds = [
|
||||
Self.monthlyProductIdAlt,
|
||||
Self.monthlyProductId,
|
||||
Self.monthlyProductIdLegacy,
|
||||
Self.monthlyProductIdShort
|
||||
]
|
||||
|
||||
if let match = products.first(where: { product in
|
||||
prioritizedIds.contains(product.id) && product.type == .autoRenewable
|
||||
}) {
|
||||
return match
|
||||
}
|
||||
|
||||
if let match = products.first(where: { prioritizedIds.contains($0.id) }) {
|
||||
return match
|
||||
}
|
||||
|
||||
if let match = products.first(where: { $0.id.contains("monthly") && $0.type == .autoRenewable }) {
|
||||
return match
|
||||
}
|
||||
|
||||
if let match = products.first(where: { $0.type == .autoRenewable }) {
|
||||
return match
|
||||
}
|
||||
|
||||
return products.first
|
||||
}
|
||||
|
||||
var debugProductIds: [String] { productIds }
|
||||
|
||||
static func hasActiveSubscription(productIds: [String] = [
|
||||
StoreManager.monthlyProductId,
|
||||
StoreManager.monthlyProductIdAlt,
|
||||
StoreManager.monthlyProductIdLegacy,
|
||||
StoreManager.monthlyProductIdShort
|
||||
]) async -> Bool {
|
||||
for await result in Transaction.currentEntitlements {
|
||||
if case .verified(let transaction) = result,
|
||||
productIds.contains(transaction.productID),
|
||||
transaction.revocationDate == nil {
|
||||
if let expirationDate = transaction.expirationDate, expirationDate < Date() {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private struct TimeoutError: Error {}
|
||||
|
||||
private func loadProductsWithRetries(maxAttempts: Int = 3) async throws -> [Product] {
|
||||
var lastProducts: [Product] = []
|
||||
|
||||
for attempt in 1...maxAttempts {
|
||||
let products = try await loadProductsWithTimeout(seconds: 12)
|
||||
if !products.isEmpty {
|
||||
return products
|
||||
}
|
||||
lastProducts = products
|
||||
|
||||
if attempt < maxAttempts {
|
||||
try await Task.sleep(nanoseconds: 800_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
return lastProducts
|
||||
}
|
||||
|
||||
private func loadProductsWithTimeout(seconds: UInt64) async throws -> [Product] {
|
||||
let ids = productIds
|
||||
return try await withThrowingTaskGroup(of: [Product].self) { group in
|
||||
group.addTask {
|
||||
try await self.loadProductsBySingleID(ids: ids)
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: seconds * 1_000_000_000)
|
||||
throw TimeoutError()
|
||||
}
|
||||
|
||||
let firstResult = try await group.next() ?? []
|
||||
group.cancelAll()
|
||||
return firstResult
|
||||
}
|
||||
}
|
||||
|
||||
private func loadProductsBySingleID(ids: [String]) async throws -> [Product] {
|
||||
var merged: [String: Product] = [:]
|
||||
|
||||
// First, attempt a single batch request with all IDs.
|
||||
let batchItems = try await Product.products(for: ids)
|
||||
for item in batchItems {
|
||||
merged[item.id] = item
|
||||
}
|
||||
if !merged.isEmpty {
|
||||
return Array(merged.values)
|
||||
}
|
||||
|
||||
// Fallback to one-by-one requests for better resilience/debugging.
|
||||
for id in ids {
|
||||
let items = try await Product.products(for: [id])
|
||||
for item in items {
|
||||
merged[item.id] = item
|
||||
}
|
||||
}
|
||||
return Array(merged.values)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
final class DishViewModel: ObservableObject {
|
||||
@Published var name: String = ""
|
||||
@Published var descriptionText: String = ""
|
||||
@Published var selectedTagIds: Set<UUID> = []
|
||||
@Published var showTagSelector: Bool = false
|
||||
@Published var showDeleteAlert: Bool = false
|
||||
@Published var showAssignedDeleteAlert: Bool = false
|
||||
|
||||
var editingDish: Dish?
|
||||
|
||||
var isEditing: Bool { editingDish != nil }
|
||||
|
||||
var isValid: Bool {
|
||||
!name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
|
||||
func loadDish(_ dish: Dish) {
|
||||
editingDish = dish
|
||||
name = dish.name
|
||||
descriptionText = dish.descriptionText ?? ""
|
||||
selectedTagIds = Set(dish.tagIds)
|
||||
}
|
||||
|
||||
func reset() {
|
||||
editingDish = nil
|
||||
name = ""
|
||||
descriptionText = ""
|
||||
selectedTagIds = []
|
||||
}
|
||||
|
||||
func save(context: ModelContext) {
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmedName.isEmpty else { return }
|
||||
|
||||
if let dish = editingDish {
|
||||
dish.name = trimmedName
|
||||
dish.descriptionText = descriptionText.isEmpty ? nil : descriptionText
|
||||
dish.tagIds = Array(selectedTagIds)
|
||||
} else {
|
||||
let dish = Dish(
|
||||
name: trimmedName,
|
||||
descriptionText: descriptionText.isEmpty ? nil : descriptionText,
|
||||
tagIds: Array(selectedTagIds)
|
||||
)
|
||||
context.insert(dish)
|
||||
}
|
||||
|
||||
try? context.save()
|
||||
reset()
|
||||
}
|
||||
|
||||
func deleteDish(context: ModelContext) -> Bool {
|
||||
guard let dish = editingDish else { return false }
|
||||
|
||||
if isDishAssignedInCurrentWeek(dishId: dish.id, context: context) {
|
||||
showAssignedDeleteAlert = true
|
||||
return false
|
||||
}
|
||||
|
||||
context.delete(dish)
|
||||
try? context.save()
|
||||
reset()
|
||||
return true
|
||||
}
|
||||
|
||||
func canDelete(currentPlan: WeekPlan?) -> Bool {
|
||||
guard let dish = editingDish, let plan = currentPlan else { return true }
|
||||
return !plan.slots.contains { $0.dishId == dish.id }
|
||||
}
|
||||
|
||||
private func isDishAssignedInCurrentWeek(dishId: UUID, context: ModelContext) -> Bool {
|
||||
let currentWeekStart = Date().startOfWeek()
|
||||
let descriptor = FetchDescriptor<WeekPlan>(
|
||||
predicate: #Predicate<WeekPlan> { plan in
|
||||
plan.weekStartDate == currentWeekStart
|
||||
}
|
||||
)
|
||||
|
||||
guard let currentPlan = try? context.fetch(descriptor).first else {
|
||||
return false
|
||||
}
|
||||
|
||||
return currentPlan.slots.contains { $0.dishId == dishId }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
final class HomeViewModel: ObservableObject {
|
||||
struct InvalidDropContext: Identifiable {
|
||||
let id = UUID()
|
||||
let dish: Dish
|
||||
let slot: MealSlot
|
||||
}
|
||||
|
||||
private struct SlotSnapshot {
|
||||
let dishId: UUID?
|
||||
let isRuleOverridden: Bool
|
||||
}
|
||||
|
||||
@Published var currentWeekStart: Date
|
||||
@Published var isAutoCompleting: Bool = false
|
||||
@Published var showConfetti: Bool = false
|
||||
@Published var showToast: Bool = false
|
||||
@Published var toastMessage: String = ""
|
||||
@Published var showResetAlert: Bool = false
|
||||
@Published var showDishForm: Bool = false
|
||||
@Published var draggedDish: Dish?
|
||||
@Published var invalidDropContext: InvalidDropContext?
|
||||
@Published private(set) var hasUndoSnapshot: Bool = false
|
||||
|
||||
private var lastWeekStartSnapshot: Date?
|
||||
private var lastSlotsSnapshot: [UUID: SlotSnapshot] = [:]
|
||||
private var isApplyingUndo: Bool = false
|
||||
|
||||
init() {
|
||||
self.currentWeekStart = Date().startOfWeek()
|
||||
}
|
||||
|
||||
func goToPreviousWeek() {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
currentWeekStart = currentWeekStart.addingDays(-7)
|
||||
}
|
||||
}
|
||||
|
||||
func goToNextWeek() {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
currentWeekStart = currentWeekStart.addingDays(7)
|
||||
}
|
||||
}
|
||||
|
||||
func jumpToWeek(startDate: Date) {
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
currentWeekStart = startDate.startOfWeek()
|
||||
}
|
||||
}
|
||||
|
||||
var weekRangeText: String {
|
||||
currentWeekStart.formattedWeekRange()
|
||||
}
|
||||
|
||||
var canEditCurrentWeek: Bool {
|
||||
currentWeekStart >= Date().startOfWeek()
|
||||
}
|
||||
|
||||
func getOrCreateWeekPlan(context: ModelContext, settings: AppSettings) -> WeekPlan? {
|
||||
let start = currentWeekStart
|
||||
let descriptor = FetchDescriptor<WeekPlan>(
|
||||
predicate: #Predicate<WeekPlan> { plan in
|
||||
plan.weekStartDate == start
|
||||
}
|
||||
)
|
||||
|
||||
if let existing = try? context.fetch(descriptor).first {
|
||||
syncSlotsIfNeeded(plan: existing, settings: settings, context: context)
|
||||
return existing
|
||||
}
|
||||
|
||||
return DefaultDataService.createWeekPlan(for: currentWeekStart, settings: settings, context: context)
|
||||
}
|
||||
|
||||
func assignDish(_ dish: Dish, to slot: MealSlot, plan: WeekPlan, settings: AppSettings, isOverride: Bool = false) {
|
||||
captureUndoSnapshot(plan: plan)
|
||||
|
||||
if let oldEventId = slot.calendarEventId {
|
||||
CalendarService.shared.deleteEvent(eventId: oldEventId)
|
||||
slot.calendarEventId = nil
|
||||
}
|
||||
|
||||
slot.dishId = dish.id
|
||||
slot.isRuleOverridden = isOverride
|
||||
plan.updatedAt = Date()
|
||||
|
||||
applyCalendarSyncPolicy(plan: plan, settings: settings)
|
||||
|
||||
HapticManager.shared.notification(type: .success)
|
||||
showToastMessage(localizedString("toast_dish_assigned", language: settings.languageEnum.resolved()))
|
||||
}
|
||||
|
||||
func removeDish(from slot: MealSlot, plan: WeekPlan, settings: AppSettings) {
|
||||
captureUndoSnapshot(plan: plan)
|
||||
|
||||
if let eventId = slot.calendarEventId {
|
||||
CalendarService.shared.deleteEvent(eventId: eventId)
|
||||
slot.calendarEventId = nil
|
||||
}
|
||||
slot.dishId = nil
|
||||
slot.isRuleOverridden = false
|
||||
plan.updatedAt = Date()
|
||||
applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false)
|
||||
HapticManager.shared.notification(type: .warning)
|
||||
}
|
||||
|
||||
func autoComplete(plan: WeekPlan, dishes: [Dish], tags: [Tag], settings: AppSettings) {
|
||||
captureUndoSnapshot(plan: plan)
|
||||
isAutoCompleting = true
|
||||
|
||||
let emptySlots = plan.slots.filter { $0.dishId == nil }
|
||||
let result = AutocompleteEngine.autocomplete(
|
||||
emptySlots: emptySlots,
|
||||
currentPlan: plan,
|
||||
allDishes: dishes,
|
||||
allTags: tags
|
||||
)
|
||||
|
||||
plan.updatedAt = Date()
|
||||
applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false)
|
||||
|
||||
Task {
|
||||
for _ in result.filledSlots {
|
||||
try? await Task.sleep(nanoseconds: 150_000_000)
|
||||
HapticManager.shared.impact(style: .light)
|
||||
}
|
||||
|
||||
isAutoCompleting = false
|
||||
|
||||
if result.unfilledCount > 0 {
|
||||
showToastMessage(localizedString("toast_cannot_complete", language: settings.languageEnum.resolved()))
|
||||
} else {
|
||||
showConfetti = true
|
||||
HapticManager.shared.notification(type: .success)
|
||||
showToastMessage(localizedString("toast_week_complete", language: settings.languageEnum.resolved()))
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
self.showConfetti = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resetWeek(plan: WeekPlan, settings: AppSettings) {
|
||||
captureUndoSnapshot(plan: plan)
|
||||
|
||||
for slot in plan.slots {
|
||||
if let eventId = slot.calendarEventId {
|
||||
CalendarService.shared.deleteEvent(eventId: eventId)
|
||||
slot.calendarEventId = nil
|
||||
}
|
||||
slot.dishId = nil
|
||||
slot.isRuleOverridden = false
|
||||
}
|
||||
plan.updatedAt = Date()
|
||||
HapticManager.shared.notification(type: .warning)
|
||||
showToastMessage(localizedString("toast_week_reset", language: settings.languageEnum.resolved()))
|
||||
}
|
||||
|
||||
func assignDishToFirstFreeSlot(
|
||||
_ dish: Dish,
|
||||
plan: WeekPlan,
|
||||
settings: AppSettings,
|
||||
allTags: [Tag],
|
||||
allDishes: [Dish]
|
||||
) {
|
||||
guard let slot = firstFreeSlot(in: plan) else {
|
||||
showToastMessage(localizedString("toast_no_free_slots", language: settings.languageEnum.resolved()))
|
||||
HapticManager.shared.notification(type: .warning)
|
||||
return
|
||||
}
|
||||
|
||||
let isValid = AutocompleteEngine.validateDrop(
|
||||
dish: dish,
|
||||
slot: slot,
|
||||
plan: plan,
|
||||
allTags: allTags,
|
||||
allDishes: allDishes
|
||||
)
|
||||
|
||||
if isValid {
|
||||
assignDish(dish, to: slot, plan: plan, settings: settings)
|
||||
} else {
|
||||
confirmInvalidDrop(dish, to: slot, plan: plan, settings: settings)
|
||||
}
|
||||
}
|
||||
|
||||
func syncWeekToCalendar(plan: WeekPlan, dishes: [Dish], settings: AppSettings) {
|
||||
guard settings.syncEnabled else { return }
|
||||
syncAllAssignedSlotsToCalendar(plan: plan, dishes: dishes, settings: settings)
|
||||
showToastMessage(localizedString("toast_calendar_synced", language: settings.languageEnum.resolved()))
|
||||
}
|
||||
|
||||
func moveOrSwapDish(
|
||||
from sourceSlot: MealSlot,
|
||||
to targetSlot: MealSlot,
|
||||
plan: WeekPlan,
|
||||
settings: AppSettings,
|
||||
allTags: [Tag],
|
||||
allDishes: [Dish]
|
||||
) {
|
||||
guard sourceSlot.id != targetSlot.id else { return }
|
||||
guard let sourceDishId = sourceSlot.dishId else { return }
|
||||
captureUndoSnapshot(plan: plan)
|
||||
|
||||
if let sourceEventId = sourceSlot.calendarEventId {
|
||||
CalendarService.shared.deleteEvent(eventId: sourceEventId)
|
||||
sourceSlot.calendarEventId = nil
|
||||
}
|
||||
if let targetEventId = targetSlot.calendarEventId {
|
||||
CalendarService.shared.deleteEvent(eventId: targetEventId)
|
||||
targetSlot.calendarEventId = nil
|
||||
}
|
||||
|
||||
let targetDishId = targetSlot.dishId
|
||||
sourceSlot.dishId = targetDishId
|
||||
targetSlot.dishId = sourceDishId
|
||||
sourceSlot.isRuleOverridden = false
|
||||
targetSlot.isRuleOverridden = false
|
||||
|
||||
updateRuleOverrideFlag(for: sourceSlot, plan: plan, allTags: allTags, allDishes: allDishes)
|
||||
updateRuleOverrideFlag(for: targetSlot, plan: plan, allTags: allTags, allDishes: allDishes)
|
||||
|
||||
plan.updatedAt = Date()
|
||||
applyCalendarSyncPolicy(plan: plan, settings: settings)
|
||||
HapticManager.shared.notification(type: .success)
|
||||
showToastMessage(localizedString("toast_dish_assigned", language: settings.languageEnum.resolved()))
|
||||
}
|
||||
|
||||
func copyFromPreviousWeek(
|
||||
currentPlan: WeekPlan,
|
||||
previousPlan: WeekPlan?,
|
||||
settings: AppSettings,
|
||||
allTags: [Tag],
|
||||
allDishes: [Dish]
|
||||
) {
|
||||
guard let previousPlan else {
|
||||
showToastMessage(localizedString("toast_previous_week_empty", language: settings.languageEnum.resolved()))
|
||||
HapticManager.shared.notification(type: .warning)
|
||||
return
|
||||
}
|
||||
|
||||
captureUndoSnapshot(plan: currentPlan)
|
||||
|
||||
var previousByKey: [SlotKey: MealSlot] = [:]
|
||||
for slot in previousPlan.slots {
|
||||
previousByKey[SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType)] = slot
|
||||
}
|
||||
|
||||
let dishIds = Set(allDishes.map(\.id))
|
||||
for slot in currentPlan.slots {
|
||||
if let eventId = slot.calendarEventId {
|
||||
CalendarService.shared.deleteEvent(eventId: eventId)
|
||||
slot.calendarEventId = nil
|
||||
}
|
||||
|
||||
let key = SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType)
|
||||
if let previousDishId = previousByKey[key]?.dishId, dishIds.contains(previousDishId) {
|
||||
slot.dishId = previousDishId
|
||||
} else {
|
||||
slot.dishId = nil
|
||||
}
|
||||
slot.isRuleOverridden = false
|
||||
updateRuleOverrideFlag(for: slot, plan: currentPlan, allTags: allTags, allDishes: allDishes)
|
||||
}
|
||||
|
||||
currentPlan.updatedAt = Date()
|
||||
applyCalendarSyncPolicy(plan: currentPlan, settings: settings)
|
||||
HapticManager.shared.notification(type: .success)
|
||||
showToastMessage(localizedString("toast_copied_previous_week", language: settings.languageEnum.resolved()))
|
||||
}
|
||||
|
||||
func canUndo(for plan: WeekPlan) -> Bool {
|
||||
hasUndoSnapshot && lastWeekStartSnapshot == plan.weekStartDate
|
||||
}
|
||||
|
||||
func undoLastAction(plan: WeekPlan, settings: AppSettings) {
|
||||
guard canUndo(for: plan) else { return }
|
||||
isApplyingUndo = true
|
||||
|
||||
for slot in plan.slots {
|
||||
if let eventId = slot.calendarEventId {
|
||||
CalendarService.shared.deleteEvent(eventId: eventId)
|
||||
}
|
||||
slot.calendarEventId = nil
|
||||
|
||||
if let snap = lastSlotsSnapshot[slot.id] {
|
||||
slot.dishId = snap.dishId
|
||||
slot.isRuleOverridden = snap.isRuleOverridden
|
||||
} else {
|
||||
slot.dishId = nil
|
||||
slot.isRuleOverridden = false
|
||||
}
|
||||
}
|
||||
|
||||
plan.updatedAt = Date()
|
||||
applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false)
|
||||
clearUndoSnapshot()
|
||||
isApplyingUndo = false
|
||||
|
||||
HapticManager.shared.notification(type: .success)
|
||||
showToastMessage(localizedString("toast_undo_applied", language: settings.languageEnum.resolved()))
|
||||
}
|
||||
|
||||
private func showToastMessage(_ message: String) {
|
||||
toastMessage = message
|
||||
withAnimation(.spring()) {
|
||||
showToast = true
|
||||
}
|
||||
}
|
||||
|
||||
private func captureUndoSnapshot(plan: WeekPlan) {
|
||||
guard !isApplyingUndo else { return }
|
||||
lastWeekStartSnapshot = plan.weekStartDate
|
||||
lastSlotsSnapshot = Dictionary(uniqueKeysWithValues: plan.slots.map { slot in
|
||||
(slot.id, SlotSnapshot(dishId: slot.dishId, isRuleOverridden: slot.isRuleOverridden))
|
||||
})
|
||||
hasUndoSnapshot = true
|
||||
}
|
||||
|
||||
private func clearUndoSnapshot() {
|
||||
lastWeekStartSnapshot = nil
|
||||
lastSlotsSnapshot.removeAll()
|
||||
hasUndoSnapshot = false
|
||||
}
|
||||
|
||||
private func firstFreeSlot(in plan: WeekPlan) -> MealSlot? {
|
||||
plan.slots
|
||||
.filter { $0.dishId == nil }
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.dayOfWeek != rhs.dayOfWeek {
|
||||
return lhs.dayOfWeek < rhs.dayOfWeek
|
||||
}
|
||||
return mealTypeOrder(lhs.mealType) < mealTypeOrder(rhs.mealType)
|
||||
}
|
||||
.first
|
||||
}
|
||||
|
||||
private func mealTypeOrder(_ raw: String) -> Int {
|
||||
raw == MealType.lunch.rawValue ? 0 : 1
|
||||
}
|
||||
|
||||
private func applyCalendarSyncPolicy(plan: WeekPlan, settings: AppSettings, shouldNotify: Bool = true) {
|
||||
guard settings.syncEnabled else { return }
|
||||
|
||||
switch settings.syncModeEnum {
|
||||
case .weekComplete:
|
||||
if plan.slots.allSatisfy({ $0.dishId != nil }) {
|
||||
let descriptor = FetchDescriptor<Dish>()
|
||||
let dishes = (try? plan.modelContext?.fetch(descriptor)) ?? []
|
||||
syncAllAssignedSlotsToCalendar(plan: plan, dishes: dishes, settings: settings)
|
||||
if shouldNotify {
|
||||
showToastMessage(localizedString("toast_calendar_synced", language: settings.languageEnum.resolved()))
|
||||
}
|
||||
} else {
|
||||
clearCalendarEvents(for: plan)
|
||||
}
|
||||
case .manual:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func clearCalendarEvents(for plan: WeekPlan) {
|
||||
for slot in plan.slots {
|
||||
if let eventId = slot.calendarEventId {
|
||||
CalendarService.shared.deleteEvent(eventId: eventId)
|
||||
slot.calendarEventId = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func syncAllAssignedSlotsToCalendar(plan: WeekPlan, dishes: [Dish], settings: AppSettings) {
|
||||
let dishById = Dictionary(uniqueKeysWithValues: dishes.map { ($0.id, $0) })
|
||||
for slot in plan.slots {
|
||||
if let oldEventId = slot.calendarEventId {
|
||||
CalendarService.shared.deleteEvent(eventId: oldEventId)
|
||||
slot.calendarEventId = nil
|
||||
}
|
||||
|
||||
guard let dishId = slot.dishId, let dish = dishById[dishId] else { continue }
|
||||
slot.calendarEventId = CalendarService.shared.createEvent(
|
||||
slot: slot,
|
||||
dishName: dish.name,
|
||||
dishDescription: dish.descriptionText,
|
||||
weekStartDate: currentWeekStart,
|
||||
settings: settings
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateRuleOverrideFlag(for slot: MealSlot, plan: WeekPlan, allTags: [Tag], allDishes: [Dish]) {
|
||||
guard let dishId = slot.dishId, let dish = allDishes.first(where: { $0.id == dishId }) else {
|
||||
slot.isRuleOverridden = false
|
||||
return
|
||||
}
|
||||
let valid = AutocompleteEngine.validateDrop(
|
||||
dish: dish,
|
||||
slot: slot,
|
||||
plan: plan,
|
||||
allTags: allTags,
|
||||
allDishes: allDishes
|
||||
)
|
||||
slot.isRuleOverridden = !valid
|
||||
}
|
||||
|
||||
func confirmInvalidDrop(_ dish: Dish, to slot: MealSlot, plan: WeekPlan, settings: AppSettings) {
|
||||
invalidDropContext = InvalidDropContext(dish: dish, slot: slot)
|
||||
HapticManager.shared.notification(type: .warning)
|
||||
}
|
||||
|
||||
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] = {
|
||||
switch settings.mealWindowsEnum {
|
||||
case .dinnerOnly: return ["dinner"]
|
||||
case .lunchOnly: return ["lunch"]
|
||||
case .both: return ["lunch", "dinner"]
|
||||
}
|
||||
}()
|
||||
|
||||
var desired = Set<SlotKey>()
|
||||
for day in 0...maxDay {
|
||||
for mealType in mealTypes {
|
||||
desired.insert(SlotKey(dayOfWeek: day, mealType: mealType))
|
||||
}
|
||||
}
|
||||
|
||||
var existingByKey: [SlotKey: MealSlot] = [:]
|
||||
for slot in plan.slots {
|
||||
let key = SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType)
|
||||
if existingByKey[key] == nil {
|
||||
existingByKey[key] = slot
|
||||
}
|
||||
}
|
||||
var changed = false
|
||||
|
||||
let toDelete = plan.slots.filter { slot in
|
||||
!desired.contains(SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType))
|
||||
}
|
||||
for slot in toDelete {
|
||||
if let eventId = slot.calendarEventId {
|
||||
CalendarService.shared.deleteEvent(eventId: eventId)
|
||||
}
|
||||
plan.slots.removeAll { $0.id == slot.id }
|
||||
context.delete(slot)
|
||||
changed = true
|
||||
}
|
||||
|
||||
for key in desired where existingByKey[key] == nil {
|
||||
let slot = MealSlot(dayOfWeek: key.dayOfWeek, mealType: key.mealType)
|
||||
slot.weekPlan = plan
|
||||
plan.slots.append(slot)
|
||||
context.insert(slot)
|
||||
changed = true
|
||||
}
|
||||
|
||||
if changed {
|
||||
plan.updatedAt = Date()
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
final class OnboardingViewModel: ObservableObject {
|
||||
@Published var currentStep: Int = 0
|
||||
@Published var selectedMealWindows: MealWindows = .dinnerOnly
|
||||
@Published var includeWeekends: Bool = true
|
||||
@Published var syncCalendar: Bool = false
|
||||
@Published var selectedCalendarId: String?
|
||||
@Published var lunchTime: Date = {
|
||||
var c = DateComponents(); c.hour = 14; c.minute = 0
|
||||
return Calendar.current.date(from: c) ?? Date()
|
||||
}()
|
||||
@Published var dinnerTime: Date = {
|
||||
var c = DateComponents(); c.hour = 21; c.minute = 0
|
||||
return Calendar.current.date(from: c) ?? Date()
|
||||
}()
|
||||
|
||||
// First dishes
|
||||
@Published var newDishName: String = ""
|
||||
@Published var newDishTags: Set<UUID> = []
|
||||
@Published var addedDishes: [(name: String, tagIds: [UUID])] = []
|
||||
|
||||
let totalSteps = 5
|
||||
|
||||
var canContinue: Bool {
|
||||
switch currentStep {
|
||||
case 0: return true // Welcome
|
||||
case 1: return true // Meal windows (always has selection)
|
||||
case 2: return true // Weekends (toggle)
|
||||
case 3: return true // Calendar (optional)
|
||||
case 4: return addedDishes.count >= 2 // Need at least 2 dishes
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
var canAddDish: Bool {
|
||||
!newDishName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
func addDish(defaultTagId: UUID? = nil) {
|
||||
guard canAddDish else { return }
|
||||
let normalizedName = newDishName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !addedDishes.contains(where: { $0.name.lowercased() == normalizedName.lowercased() }) else { return }
|
||||
|
||||
let tagIds: [UUID]
|
||||
if !newDishTags.isEmpty {
|
||||
tagIds = Array(newDishTags)
|
||||
} else if let defaultTagId {
|
||||
tagIds = [defaultTagId]
|
||||
} else {
|
||||
tagIds = []
|
||||
}
|
||||
|
||||
addedDishes.append((name: normalizedName, tagIds: tagIds))
|
||||
newDishName = ""
|
||||
newDishTags = []
|
||||
}
|
||||
|
||||
func removeDish(at index: Int) {
|
||||
guard addedDishes.indices.contains(index) else { return }
|
||||
addedDishes.remove(at: index)
|
||||
}
|
||||
|
||||
func addSuggestedDish(name: String, preferredTagNames: [String], availableTags: [Tag]) {
|
||||
let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedName.isEmpty else { return }
|
||||
guard !addedDishes.contains(where: { $0.name.lowercased() == normalizedName.lowercased() }) else { return }
|
||||
|
||||
let matchingTags = availableTags.filter { tag in
|
||||
preferredTagNames.contains(where: { preferred in
|
||||
tag.name.caseInsensitiveCompare(preferred) == .orderedSame ||
|
||||
tag.nameEN.caseInsensitiveCompare(preferred) == .orderedSame
|
||||
})
|
||||
}
|
||||
|
||||
let tagIds: [UUID]
|
||||
if !matchingTags.isEmpty {
|
||||
tagIds = matchingTags.map(\.id)
|
||||
} else if let fallback = availableTags.sorted(by: { $0.sortOrder < $1.sortOrder }).first {
|
||||
tagIds = [fallback.id]
|
||||
} else {
|
||||
tagIds = []
|
||||
}
|
||||
|
||||
addedDishes.append((name: normalizedName, tagIds: tagIds))
|
||||
}
|
||||
|
||||
func nextStep() {
|
||||
if currentStep < totalSteps - 1 {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
currentStep += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func previousStep() {
|
||||
if currentStep > 0 {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
currentStep -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func completeOnboarding(context: ModelContext) -> Bool {
|
||||
// Create or get settings
|
||||
let descriptor = FetchDescriptor<AppSettings>()
|
||||
let settings = (try? context.fetch(descriptor))?.first ?? {
|
||||
let s = AppSettings()
|
||||
context.insert(s)
|
||||
return s
|
||||
}()
|
||||
|
||||
settings.mealWindowsEnum = selectedMealWindows
|
||||
settings.includeWeekends = includeWeekends
|
||||
settings.syncEnabled = syncCalendar
|
||||
settings.calendarId = selectedCalendarId
|
||||
settings.lunchTime = lunchTime
|
||||
settings.dinnerTime = dinnerTime
|
||||
settings.onboardingCompleted = true
|
||||
|
||||
// Create default tags if not exist
|
||||
let tagDescriptor = FetchDescriptor<Tag>()
|
||||
if (try? context.fetch(tagDescriptor))?.isEmpty ?? true {
|
||||
DefaultDataService.createDefaultTags(context: context)
|
||||
}
|
||||
|
||||
// Create dishes
|
||||
for dishData in addedDishes {
|
||||
let dish = Dish(name: dishData.name, tagIds: dishData.tagIds)
|
||||
context.insert(dish)
|
||||
}
|
||||
|
||||
// Create first week plan
|
||||
let weekStart = Date().startOfWeek()
|
||||
let _ = DefaultDataService.createWeekPlan(for: weekStart, settings: settings, context: context)
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
return true
|
||||
} catch {
|
||||
print("Onboarding save failed: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import EventKit
|
||||
|
||||
@MainActor
|
||||
final class SettingsViewModel: ObservableObject {
|
||||
@Published var availableCalendars: [EKCalendar] = []
|
||||
@Published var showCalendarPermissionAlert: Bool = false
|
||||
|
||||
func loadCalendars() {
|
||||
availableCalendars = CalendarService.shared.availableCalendars()
|
||||
}
|
||||
|
||||
func requestCalendarAccess() async -> Bool {
|
||||
let granted = await CalendarService.shared.requestAccess()
|
||||
if !granted {
|
||||
showCalendarPermissionAlert = true
|
||||
} else {
|
||||
loadCalendars()
|
||||
}
|
||||
return granted
|
||||
}
|
||||
|
||||
func openSystemSettings() {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
func updateCalendarEvents(settings: AppSettings, plan: WeekPlan?) {
|
||||
guard let plan = plan, settings.syncEnabled else { return }
|
||||
CalendarService.shared.updateEventsTime(
|
||||
slots: plan.slots,
|
||||
weekStartDate: plan.weekStartDate,
|
||||
settings: settings
|
||||
)
|
||||
}
|
||||
|
||||
func resetAllData(context: ModelContext) {
|
||||
do {
|
||||
try deleteAll(of: MealSlot.self, in: context)
|
||||
try deleteAll(of: WeekPlan.self, in: context)
|
||||
try deleteAll(of: Dish.self, in: context)
|
||||
try deleteAll(of: Tag.self, in: context)
|
||||
try deleteAll(of: AppSettings.self, in: context)
|
||||
|
||||
DefaultDataService.createDefaultSettings(context: context)
|
||||
DefaultDataService.createDefaultTags(context: context)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("Failed to reset all data: \(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteAll<T: PersistentModel>(of type: T.Type, in context: ModelContext) throws {
|
||||
let descriptor = FetchDescriptor<T>()
|
||||
let models = try context.fetch(descriptor)
|
||||
for model in models {
|
||||
context.delete(model)
|
||||
}
|
||||
try context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
final class TagViewModel: ObservableObject {
|
||||
@Published var editingTag: Tag?
|
||||
@Published var maxPerWeek: Int? = nil
|
||||
@Published var noConsecutive: Bool = false
|
||||
@Published var noDuplicateInDay: Bool = false
|
||||
@Published var mealTypeRestriction: String? = nil
|
||||
@Published var useMaxLimit: Bool = true
|
||||
|
||||
func loadTag(_ tag: Tag) {
|
||||
editingTag = tag
|
||||
maxPerWeek = tag.maxPerWeek
|
||||
noConsecutive = tag.noConsecutive
|
||||
noDuplicateInDay = tag.noDuplicateInDay
|
||||
mealTypeRestriction = tag.mealTypeRestriction
|
||||
useMaxLimit = tag.maxPerWeek != nil
|
||||
}
|
||||
|
||||
func save() {
|
||||
guard let tag = editingTag else { return }
|
||||
tag.maxPerWeek = useMaxLimit ? (maxPerWeek ?? 3) : nil
|
||||
tag.noConsecutive = noConsecutive
|
||||
tag.noDuplicateInDay = noDuplicateInDay
|
||||
tag.mealTypeRestriction = mealTypeRestriction
|
||||
}
|
||||
|
||||
func reset() {
|
||||
editingTag = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct DishFormView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Query private var tags: [Tag]
|
||||
@Query private var dishes: [Dish]
|
||||
@Query private var allSettings: [AppSettings]
|
||||
@StateObject private var viewModel = DishViewModel()
|
||||
@State private var showPremium = false
|
||||
@State private var showDishLimitUpsell = false
|
||||
|
||||
var dish: Dish?
|
||||
|
||||
private var language: AppLanguage {
|
||||
(allSettings.first?.languageEnum ?? .system).resolved()
|
||||
}
|
||||
|
||||
private var reachedFreeDishLimit: Bool {
|
||||
guard let settings = allSettings.first else { return false }
|
||||
if viewModel.isEditing { return false }
|
||||
return PremiumAccess.hasReachedFreeDishLimit(dishCount: dishes.count, isPremium: settings.isPremium)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color.mealMoodBackground.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Name field
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("dish_name_label")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
|
||||
TextField(
|
||||
text: $viewModel.name,
|
||||
prompt: Text("dish_name_placeholder").foregroundColor(.mealMoodTextSecondary.opacity(0.6))
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.tint(.mealMoodCoral)
|
||||
.padding(14)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
// Description field
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("dish_description_label")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
|
||||
TextField(
|
||||
text: $viewModel.descriptionText,
|
||||
prompt: Text("dish_description_placeholder").foregroundColor(.mealMoodTextSecondary.opacity(0.6)),
|
||||
axis: .vertical
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.tint(.mealMoodCoral)
|
||||
.lineLimit(2...4)
|
||||
.padding(14)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
// Tags section
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("dish_tags_label")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
|
||||
if !viewModel.selectedTagIds.isEmpty {
|
||||
FlowLayout(spacing: 6) {
|
||||
ForEach(tags.filter { viewModel.selectedTagIds.contains($0.id) }) { tag in
|
||||
HStack(spacing: 4) {
|
||||
TagPill(name: tag.localizedName(language: language), color: tag.color)
|
||||
Button {
|
||||
viewModel.selectedTagIds.remove(tag.id)
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.showTagSelector = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle")
|
||||
Text("dish_add_tag")
|
||||
}
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
}
|
||||
|
||||
if showDishLimitUpsell && reachedFreeDishLimit {
|
||||
PremiumUpsellBanner(
|
||||
messageKey: "premium_limit_dishes",
|
||||
actionTitleKey: "premium_subscribe"
|
||||
) {
|
||||
showPremium = true
|
||||
}
|
||||
}
|
||||
|
||||
// Delete button (edit mode only)
|
||||
if viewModel.isEditing {
|
||||
Button {
|
||||
viewModel.showDeleteAlert = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "trash")
|
||||
Text("dish_delete")
|
||||
}
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodError)
|
||||
.padding(.vertical, 14)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.mealMoodError.opacity(0.1))
|
||||
.cornerRadius(14)
|
||||
}
|
||||
.padding(.top, 20)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
.navigationTitle(viewModel.isEditing ? String(localized: "dish_edit_title") : String(localized: "dish_new_title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("dish_cancel") { dismiss() }
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
guard !reachedFreeDishLimit else {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showDishLimitUpsell = true
|
||||
}
|
||||
showPremium = true
|
||||
HapticManager.shared.notification(type: .warning)
|
||||
return
|
||||
}
|
||||
viewModel.save(context: context)
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "checkmark")
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(viewModel.isValid ? .mealMoodCoral : .gray)
|
||||
}
|
||||
.disabled(!viewModel.isValid)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showTagSelector) {
|
||||
TagSelectorSheet(tags: tags, selectedTagIds: $viewModel.selectedTagIds)
|
||||
}
|
||||
.sheet(isPresented: $showPremium) {
|
||||
if let settings = allSettings.first {
|
||||
NavigationStack { PremiumView(settings: settings) }
|
||||
}
|
||||
}
|
||||
.alert("dish_delete_title", isPresented: $viewModel.showDeleteAlert) {
|
||||
Button("dish_cancel", role: .cancel) {}
|
||||
Button("dish_delete", role: .destructive) {
|
||||
let deleted = viewModel.deleteDish(context: context)
|
||||
if deleted {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("dish_delete_message")
|
||||
}
|
||||
.alert("dish_delete_blocked_title", isPresented: $viewModel.showAssignedDeleteAlert) {
|
||||
Button("dish_delete_blocked_ok", role: .cancel) {}
|
||||
} message: {
|
||||
Text("dish_delete_blocked_message")
|
||||
}
|
||||
.onAppear {
|
||||
if let dish = dish {
|
||||
viewModel.loadDish(dish)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simple flow layout for tags
|
||||
struct FlowLayout: Layout {
|
||||
var spacing: CGFloat = 8
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let result = arrange(proposal: proposal, subviews: subviews)
|
||||
return result.size
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
let result = arrange(proposal: proposal, subviews: subviews)
|
||||
for (index, position) in result.positions.enumerated() {
|
||||
subviews[index].place(at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), proposal: .unspecified)
|
||||
}
|
||||
}
|
||||
|
||||
private func arrange(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) {
|
||||
let maxWidth = proposal.width ?? .infinity
|
||||
var positions: [CGPoint] = []
|
||||
var x: CGFloat = 0
|
||||
var y: CGFloat = 0
|
||||
var maxHeight: CGFloat = 0
|
||||
var rowHeight: CGFloat = 0
|
||||
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
if x + size.width > maxWidth && x > 0 {
|
||||
x = 0
|
||||
y += rowHeight + spacing
|
||||
rowHeight = 0
|
||||
}
|
||||
positions.append(CGPoint(x: x, y: y))
|
||||
rowHeight = max(rowHeight, size.height)
|
||||
x += size.width + spacing
|
||||
maxHeight = max(maxHeight, y + rowHeight)
|
||||
}
|
||||
|
||||
return (CGSize(width: maxWidth, height: maxHeight), positions)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct DishListView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@Query(sort: \Dish.createdAt, order: .reverse) private var dishes: [Dish]
|
||||
@Query private var tags: [Tag]
|
||||
@Query private var allSettings: [AppSettings]
|
||||
@Query private var weekPlans: [WeekPlan]
|
||||
@State private var showDishForm = false
|
||||
@State private var editingDish: Dish?
|
||||
@State private var showDeleteBlockedAlert = false
|
||||
|
||||
private var language: AppLanguage {
|
||||
(allSettings.first?.languageEnum ?? .system).resolved()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.mealMoodBackground.ignoresSafeArea()
|
||||
|
||||
if dishes.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "fork.knife")
|
||||
.font(.system(size: 50))
|
||||
.foregroundColor(Color(hex: "#C4C4C4"))
|
||||
Text("dish_list_empty")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
PrimaryButton(title: String(localized: "home_add_dish"), action: { showDishForm = true })
|
||||
.padding(.horizontal, 60)
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
ForEach(dishes) { dish in
|
||||
let dishTags = tags.filter { dish.tagIds.contains($0.id) }
|
||||
Button {
|
||||
editingDish = dish
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(dish.name)
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
if let desc = dish.descriptionText, !desc.isEmpty {
|
||||
Text(desc)
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
ForEach(dishTags.prefix(2)) { tag in
|
||||
TagPill(name: tag.localizedName(language: language), color: tag.color)
|
||||
}
|
||||
}
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
.onDelete(perform: deleteDishes)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
.navigationTitle("home_my_dishes")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showDishForm = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showDishForm) {
|
||||
DishFormView()
|
||||
}
|
||||
.sheet(item: $editingDish) { dish in
|
||||
DishFormView(dish: dish)
|
||||
}
|
||||
.alert("dish_delete_blocked_title", isPresented: $showDeleteBlockedAlert) {
|
||||
Button("dish_delete_blocked_ok", role: .cancel) {}
|
||||
} message: {
|
||||
Text("dish_delete_blocked_message")
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteDishes(at offsets: IndexSet) {
|
||||
let currentWeekStart = Date().startOfWeek()
|
||||
let currentWeekPlan = weekPlans.first { $0.weekStartDate == currentWeekStart }
|
||||
|
||||
for index in offsets {
|
||||
let dish = dishes[index]
|
||||
if currentWeekPlan?.slots.contains(where: { $0.dishId == dish.id }) == true {
|
||||
showDeleteBlockedAlert = true
|
||||
continue
|
||||
}
|
||||
context.delete(dish)
|
||||
}
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct TagSelectorSheet: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
let tags: [Tag]
|
||||
@Query(sort: \Tag.sortOrder) private var storedTags: [Tag]
|
||||
@Binding var selectedTagIds: Set<UUID>
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Query private var allSettings: [AppSettings]
|
||||
|
||||
private var language: AppLanguage {
|
||||
(allSettings.first?.languageEnum ?? .system).resolved()
|
||||
}
|
||||
|
||||
private var displayedTags: [Tag] {
|
||||
let source = tags.isEmpty ? storedTags : tags
|
||||
return source.sorted(by: { $0.sortOrder < $1.sortOrder })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color.mealMoodBackground.ignoresSafeArea()
|
||||
|
||||
List {
|
||||
ForEach(displayedTags) { tag in
|
||||
Button {
|
||||
if selectedTagIds.contains(tag.id) {
|
||||
selectedTagIds.remove(tag.id)
|
||||
} else {
|
||||
selectedTagIds.insert(tag.id)
|
||||
}
|
||||
HapticManager.shared.selection()
|
||||
} label: {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: tag.color))
|
||||
.frame(width: 12, height: 12)
|
||||
|
||||
Text(tag.localizedName(language: language))
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: selectedTagIds.contains(tag.id) ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundColor(selectedTagIds.contains(tag.id) ? .mealMoodCoral : Color(hex: "#C4C4C4"))
|
||||
.font(.system(size: 22))
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
|
||||
if displayedTags.isEmpty {
|
||||
Text("tag_selector_empty")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.navigationTitle("tag_selector_title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("tag_selector_done") { dismiss() }
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
ensureDefaultTagsIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureDefaultTagsIfNeeded() {
|
||||
let descriptor = FetchDescriptor<Tag>()
|
||||
if (try? context.fetch(descriptor))?.isEmpty ?? true {
|
||||
DefaultDataService.createDefaultTags(context: context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DishDrawerView: View {
|
||||
let dishes: [Dish]
|
||||
let tags: [Tag]
|
||||
let language: AppLanguage
|
||||
let usedDishIds: Set<UUID>
|
||||
var onAddDish: () -> Void
|
||||
var onQuickAssignDish: (Dish) -> Void
|
||||
var onEditDish: (Dish) -> Void
|
||||
var onDeleteDish: (Dish) -> Void
|
||||
@Binding var draggedDish: Dish?
|
||||
@State private var searchText: String = ""
|
||||
@State private var hideUsedThisWeek: Bool = false
|
||||
|
||||
private var filteredDishes: [Dish] {
|
||||
let term = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return dishes.filter { dish in
|
||||
let matchesSearch = term.isEmpty || dish.name.localizedCaseInsensitiveContains(term)
|
||||
let matchesUsedFilter = !hideUsedThisWeek || !usedDishIds.contains(dish.id)
|
||||
return matchesSearch && matchesUsedFilter
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "list.clipboard")
|
||||
Text("home_my_dishes")
|
||||
.font(.mealMoodH3)
|
||||
}
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: onAddDish) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 28))
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
if dishes.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "fork.knife")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(Color(hex: "#C4C4C4"))
|
||||
|
||||
Text("home_add_first_dish")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
|
||||
PrimaryButton(title: String(localized: "home_add_dish"), icon: nil, action: onAddDish)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
.padding(.vertical, 32)
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
TextField("home_my_dishes_search", text: $searchText)
|
||||
.font(.mealMoodBody)
|
||||
if !searchText.isEmpty {
|
||||
Button {
|
||||
searchText = ""
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(12)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
Button {
|
||||
hideUsedThisWeek.toggle()
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: hideUsedThisWeek ? "checkmark.square.fill" : "square")
|
||||
.foregroundColor(hideUsedThisWeek ? .mealMoodCoral : .mealMoodTextSecondary)
|
||||
Text("home_my_dishes_hide_used")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
LazyVStack(spacing: 8) {
|
||||
ForEach(filteredDishes) { dish in
|
||||
DishCardView(
|
||||
dish: dish,
|
||||
tags: tags,
|
||||
language: language,
|
||||
onEdit: { onEditDish(dish) },
|
||||
onDelete: { onDeleteDish(dish) }
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
onQuickAssignDish(dish)
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: false) {
|
||||
Button {
|
||||
onEditDish(dish)
|
||||
} label: {
|
||||
Label("dish_edit_title", systemImage: "pencil")
|
||||
}
|
||||
.tint(.mealMoodCoral)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
onDeleteDish(dish)
|
||||
} label: {
|
||||
Label("dish_delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.draggable("dish:\(dish.id.uuidString)") {
|
||||
DishCardView(dish: dish, tags: tags, language: language)
|
||||
.frame(width: 200)
|
||||
.opacity(0.8)
|
||||
}
|
||||
}
|
||||
|
||||
if filteredDishes.isEmpty {
|
||||
Text("home_my_dishes_search_empty")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
}
|
||||
|
||||
struct DishCardView: View {
|
||||
let dish: Dish
|
||||
let tags: [Tag]
|
||||
let language: AppLanguage
|
||||
var onEdit: (() -> Void)? = nil
|
||||
var onDelete: (() -> Void)? = nil
|
||||
|
||||
private var dishTags: [Tag] {
|
||||
tags.filter { dish.tagIds.contains($0.id) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(dish.name)
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
|
||||
if let desc = dish.descriptionText, !desc.isEmpty {
|
||||
Text(desc)
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 4) {
|
||||
ForEach(dishTags.prefix(3)) { tag in
|
||||
TagDot(color: tag.color, size: 10)
|
||||
}
|
||||
}
|
||||
|
||||
if onEdit != nil || onDelete != nil {
|
||||
HStack(spacing: 8) {
|
||||
if let onEdit {
|
||||
Button(action: onEdit) {
|
||||
Image(systemName: "pencil.circle")
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
if let onDelete {
|
||||
Button(action: onDelete) {
|
||||
Image(systemName: "trash.circle")
|
||||
.foregroundColor(.mealMoodError)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.mealCardStyle()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct HomeView: View {
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.modelContext) private var context
|
||||
@Query private var dishes: [Dish]
|
||||
@Query private var tags: [Tag]
|
||||
@Query(sort: \WeekPlan.weekStartDate, order: .forward) private var weekPlans: [WeekPlan]
|
||||
@Query private var allSettings: [AppSettings]
|
||||
@StateObject private var viewModel = HomeViewModel()
|
||||
@State private var editingDish: Dish?
|
||||
@State private var showPremiumFromExport: Bool = false
|
||||
@State private var showMonthlyHistory: Bool = false
|
||||
@State private var showWeekLimitUpsell: Bool = false
|
||||
@State private var wasWeekComplete: Bool = false
|
||||
@State private var selectedEmptySlotId: UUID?
|
||||
@State private var showWeekPicker: Bool = false
|
||||
@State private var weekPickerDate: Date = Date()
|
||||
@State private var showCopyPreviousConfirm: Bool = false
|
||||
|
||||
private var settings: AppSettings? { allSettings.first }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color.mealMoodBackground.ignoresSafeArea()
|
||||
|
||||
if let settings = settings,
|
||||
let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) {
|
||||
mainContent(plan: plan, settings: settings)
|
||||
}
|
||||
}
|
||||
.toast(isShowing: $viewModel.showToast, message: viewModel.toastMessage)
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarBackground(Color.mealMoodCoral.opacity(0.22), for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
HStack(spacing: 12) {
|
||||
NavigationLink(destination: SettingsView()) {
|
||||
Image(systemName: "gearshape")
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
}
|
||||
|
||||
if settings?.isPremium == true {
|
||||
Button {
|
||||
showMonthlyHistory = true
|
||||
} label: {
|
||||
Image(systemName: "calendar")
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: 10) {
|
||||
if let settings = settings {
|
||||
Button {
|
||||
viewModel.goToPreviousWeek()
|
||||
showWeekLimitUpsell = false
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
.accessibilityLabel(Text("home_previous"))
|
||||
|
||||
Button {
|
||||
weekPickerDate = viewModel.currentWeekStart
|
||||
showWeekPicker = true
|
||||
} label: {
|
||||
VStack(spacing: 2) {
|
||||
Text(viewModel.weekRangeText)
|
||||
.font(.mealMoodH2)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("home_select_week"))
|
||||
|
||||
Button {
|
||||
if canNavigateToNextWeek(settings: settings) {
|
||||
viewModel.goToNextWeek()
|
||||
showWeekLimitUpsell = false
|
||||
} else {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showWeekLimitUpsell = true
|
||||
}
|
||||
showPremiumFromExport = true
|
||||
HapticManager.shared.notification(type: .warning)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
.accessibilityLabel(Text("home_next"))
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 10) {
|
||||
if let settings = settings,
|
||||
let plan = viewModel.getOrCreateWeekPlan(context: context, settings: settings) {
|
||||
if viewModel.canEditCurrentWeek {
|
||||
Button {
|
||||
viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings)
|
||||
} label: {
|
||||
Image(systemName: "wand.and.stars")
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
viewModel.undoLastAction(plan: plan, settings: settings)
|
||||
} label: {
|
||||
Label("home_undo_last_action", systemImage: "arrow.uturn.backward")
|
||||
}
|
||||
.disabled(!viewModel.canUndo(for: plan))
|
||||
|
||||
Button {
|
||||
if plan.slots.contains(where: { $0.dishId != nil }) {
|
||||
showCopyPreviousConfirm = true
|
||||
} else {
|
||||
copyFromPreviousWeek(currentPlan: plan, settings: settings)
|
||||
}
|
||||
} label: {
|
||||
Label("home_copy_previous_week", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
viewModel.showResetAlert = true
|
||||
} label: {
|
||||
Label("home_reset", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(Text("home_complete"))
|
||||
.disabled(viewModel.isAutoCompleting || dishes.isEmpty)
|
||||
|
||||
if settings.syncEnabled && settings.syncModeEnum == .manual {
|
||||
Button {
|
||||
viewModel.syncWeekToCalendar(plan: plan, dishes: dishes, settings: settings)
|
||||
} label: {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
}
|
||||
.accessibilityLabel(Text("settings_sync_now"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func mainContent(plan: WeekPlan, settings: AppSettings) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
if showWeekLimitUpsell && !settings.isPremium {
|
||||
PremiumUpsellBanner(
|
||||
messageKey: "premium_limit_future_weeks",
|
||||
actionTitleKey: "premium_subscribe"
|
||||
) {
|
||||
showPremiumFromExport = true
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
if horizontalSizeClass == .regular {
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
VStack(spacing: 10) {
|
||||
WeekCalendarView(
|
||||
plan: plan,
|
||||
settings: settings,
|
||||
dishes: dishes,
|
||||
tags: tags,
|
||||
viewModel: viewModel,
|
||||
onTapEmptySlot: { slot in
|
||||
selectedEmptySlotId = slot.id
|
||||
}
|
||||
)
|
||||
|
||||
if isWeekComplete(plan: plan) {
|
||||
exportCallout(plan: plan, settings: settings)
|
||||
}
|
||||
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
ScrollView(showsIndicators: true) {
|
||||
dishDrawer(plan: plan, settings: settings)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
}
|
||||
.frame(width: 360)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 0)
|
||||
} else {
|
||||
VStack(spacing: 10) {
|
||||
WeekCalendarView(
|
||||
plan: plan,
|
||||
settings: settings,
|
||||
dishes: dishes,
|
||||
tags: tags,
|
||||
viewModel: viewModel,
|
||||
onTapEmptySlot: { slot in
|
||||
selectedEmptySlotId = slot.id
|
||||
}
|
||||
)
|
||||
|
||||
if isWeekComplete(plan: plan) {
|
||||
exportCallout(plan: plan, settings: settings)
|
||||
}
|
||||
|
||||
ScrollView(showsIndicators: true) {
|
||||
dishDrawer(plan: plan, settings: settings)
|
||||
.padding(.bottom, 0)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
.padding(.bottom, 0)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
if !settings.isPremium {
|
||||
AdBannerView()
|
||||
.ignoresSafeArea(.container, edges: .bottom)
|
||||
}
|
||||
}
|
||||
.alert("reset_title", isPresented: $viewModel.showResetAlert) {
|
||||
Button(String(localized: "reset_cancel"), role: .cancel) {}
|
||||
Button(String(localized: "reset_confirm"), role: .destructive) {
|
||||
viewModel.resetWeek(plan: plan, settings: settings)
|
||||
}
|
||||
} message: {
|
||||
Text("reset_message")
|
||||
}
|
||||
.alert("copy_previous_confirm_title", isPresented: $showCopyPreviousConfirm) {
|
||||
Button("reset_cancel", role: .cancel) {}
|
||||
Button("copy_previous_confirm_confirm", role: .destructive) {
|
||||
copyFromPreviousWeek(currentPlan: plan, settings: settings)
|
||||
}
|
||||
} message: {
|
||||
Text("copy_previous_confirm_message")
|
||||
}
|
||||
.alert(item: $viewModel.invalidDropContext) { context in
|
||||
Alert(
|
||||
title: Text("rule_override_title"),
|
||||
message: Text("rule_override_message"),
|
||||
primaryButton: .destructive(Text("rule_override_confirm")) {
|
||||
viewModel.assignDish(context.dish, to: context.slot, plan: plan, settings: settings, isOverride: true)
|
||||
},
|
||||
secondaryButton: .cancel(Text("reset_cancel"))
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showDishForm) {
|
||||
DishFormView()
|
||||
}
|
||||
.sheet(isPresented: $showWeekPicker) {
|
||||
NavigationStack {
|
||||
Form {
|
||||
DatePicker(
|
||||
"home_week_picker_date",
|
||||
selection: $weekPickerDate,
|
||||
displayedComponents: .date
|
||||
)
|
||||
.datePickerStyle(.graphical)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.mealMoodBackground)
|
||||
.navigationTitle("home_week_picker_title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("reset_cancel") {
|
||||
showWeekPicker = false
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("home_week_picker_go") {
|
||||
jumpToSelectedWeek()
|
||||
showWeekPicker = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
.sheet(
|
||||
isPresented: Binding(
|
||||
get: { selectedEmptySlotId != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented { selectedEmptySlotId = nil }
|
||||
}
|
||||
)
|
||||
) {
|
||||
if let slotId = selectedEmptySlotId,
|
||||
plan.slots.contains(where: { $0.id == slotId }) {
|
||||
SlotDishPickerSheet(
|
||||
dishes: dishes,
|
||||
tags: tags,
|
||||
onPickDish: { dish in
|
||||
guard let freshSlot = plan.slots.first(where: { $0.id == slotId }) else {
|
||||
selectedEmptySlotId = nil
|
||||
return
|
||||
}
|
||||
let isValid = AutocompleteEngine.validateDrop(
|
||||
dish: dish,
|
||||
slot: freshSlot,
|
||||
plan: plan,
|
||||
allTags: tags,
|
||||
allDishes: dishes
|
||||
)
|
||||
if isValid {
|
||||
viewModel.assignDish(dish, to: freshSlot, plan: plan, settings: settings)
|
||||
} else {
|
||||
// In picker flow, assign anyway and mark as override so the action is never lost.
|
||||
viewModel.assignDish(dish, to: freshSlot, plan: plan, settings: settings, isOverride: true)
|
||||
}
|
||||
selectedEmptySlotId = nil
|
||||
},
|
||||
onCreateDish: {
|
||||
selectedEmptySlotId = nil
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||
viewModel.showDishForm = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(item: $editingDish) { dish in
|
||||
DishFormView(dish: dish)
|
||||
}
|
||||
.sheet(isPresented: $showPremiumFromExport) {
|
||||
NavigationStack {
|
||||
PremiumView(settings: settings)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showMonthlyHistory) {
|
||||
MonthlyHistoryView(
|
||||
weekPlans: weekPlans,
|
||||
currentWeekStart: viewModel.currentWeekStart,
|
||||
language: settings.languageEnum.resolved()
|
||||
) { weekStart in
|
||||
viewModel.jumpToWeek(startDate: weekStart)
|
||||
showMonthlyHistory = false
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await NotificationService.shared.requestPermissionIfNeeded()
|
||||
let nextPlan = fetchWeekPlan(for: Date().startOfWeek().addingDays(7))
|
||||
NotificationService.shared.schedulePlanningReminderIfNeeded(
|
||||
nextWeekPlan: nextPlan,
|
||||
language: settings.languageEnum.resolved()
|
||||
)
|
||||
wasWeekComplete = isWeekComplete(plan: plan)
|
||||
}
|
||||
.onChange(of: plan.updatedAt) { _, _ in
|
||||
let nowComplete = isWeekComplete(plan: plan)
|
||||
if nowComplete && !wasWeekComplete {
|
||||
evaluateReviewPrompt()
|
||||
}
|
||||
wasWeekComplete = nowComplete
|
||||
}
|
||||
}
|
||||
|
||||
private struct SlotDishPickerSheet: View {
|
||||
let dishes: [Dish]
|
||||
let tags: [Tag]
|
||||
let onPickDish: (Dish) -> Void
|
||||
let onCreateDish: () -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var searchText: String = ""
|
||||
|
||||
private var filteredDishes: [Dish] {
|
||||
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
return dishes.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
return dishes
|
||||
.filter { $0.name.localizedCaseInsensitiveContains(trimmed) }
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if dishes.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
Text("home_pick_dish_no_dishes")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
Button {
|
||||
dismiss()
|
||||
onCreateDish()
|
||||
} label: {
|
||||
Label("home_pick_dish_add_new", systemImage: "plus")
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(24)
|
||||
} else {
|
||||
List {
|
||||
ForEach(filteredDishes) { dish in
|
||||
Button {
|
||||
onPickDish(dish)
|
||||
dismiss()
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(dish.name)
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
HStack(spacing: 4) {
|
||||
let dishTags = tags.filter { dish.tagIds.contains($0.id) }
|
||||
ForEach(dishTags.prefix(3), id: \.id) { tag in
|
||||
TagDot(color: tag.color, size: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
|
||||
if filteredDishes.isEmpty {
|
||||
Text("home_pick_dish_empty")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.mealMoodBackground)
|
||||
.searchable(text: $searchText, prompt: Text("home_pick_dish_search"))
|
||||
}
|
||||
}
|
||||
.background(Color.mealMoodBackground.ignoresSafeArea())
|
||||
.environment(\.colorScheme, .light)
|
||||
.navigationTitle("home_pick_dish_title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("dish_cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
dismiss()
|
||||
onCreateDish()
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel(Text("home_pick_dish_add_new"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dishDrawer(plan: WeekPlan, settings: AppSettings) -> some View {
|
||||
DishDrawerView(
|
||||
dishes: dishes,
|
||||
tags: tags,
|
||||
language: settings.languageEnum.resolved(),
|
||||
usedDishIds: Set(plan.slots.compactMap(\.dishId)),
|
||||
onAddDish: { viewModel.showDishForm = true },
|
||||
onQuickAssignDish: { dish in
|
||||
viewModel.assignDishToFirstFreeSlot(
|
||||
dish,
|
||||
plan: plan,
|
||||
settings: settings,
|
||||
allTags: tags,
|
||||
allDishes: dishes
|
||||
)
|
||||
},
|
||||
onEditDish: { dish in
|
||||
editingDish = dish
|
||||
},
|
||||
onDeleteDish: { dish in
|
||||
let isAssignedInCurrentWeek = plan.slots.contains { $0.dishId == dish.id }
|
||||
if isAssignedInCurrentWeek {
|
||||
viewModel.toastMessage = localizedString("dish_delete_blocked_message", language: settings.languageEnum.resolved())
|
||||
viewModel.showToast = true
|
||||
return
|
||||
}
|
||||
context.delete(dish)
|
||||
try? context.save()
|
||||
viewModel.toastMessage = localizedString("toast_dish_deleted", language: settings.languageEnum.resolved())
|
||||
viewModel.showToast = true
|
||||
},
|
||||
draggedDish: $viewModel.draggedDish
|
||||
)
|
||||
}
|
||||
|
||||
private func canNavigateToNextWeek(settings: AppSettings) -> Bool {
|
||||
if settings.isPremium { return true }
|
||||
let maxFreeWeek = Date().startOfWeek().addingDays(7)
|
||||
return viewModel.currentWeekStart < maxFreeWeek
|
||||
}
|
||||
|
||||
private func canNavigateToWeek(_ startDate: Date, settings: AppSettings) -> Bool {
|
||||
if settings.isPremium { return true }
|
||||
let maxFreeWeek = Date().startOfWeek().addingDays(7)
|
||||
return startDate <= maxFreeWeek
|
||||
}
|
||||
|
||||
private func jumpToSelectedWeek() {
|
||||
guard let settings else { return }
|
||||
let selectedWeekStart = weekPickerDate.startOfWeek()
|
||||
if canNavigateToWeek(selectedWeekStart, settings: settings) {
|
||||
viewModel.jumpToWeek(startDate: selectedWeekStart)
|
||||
showWeekLimitUpsell = false
|
||||
} else {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showWeekLimitUpsell = true
|
||||
}
|
||||
showPremiumFromExport = true
|
||||
HapticManager.shared.notification(type: .warning)
|
||||
}
|
||||
}
|
||||
|
||||
private func isWeekComplete(plan: WeekPlan) -> Bool {
|
||||
plan.slots.allSatisfy { $0.dishId != nil }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func exportCallout(plan: WeekPlan, settings: AppSettings) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("share_week_callout_title")
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Text("share_week_callout_subtitle")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
|
||||
if settings.isPremium,
|
||||
let image = renderWeekShareImage(plan: plan, settings: settings),
|
||||
let shareURL = persistShareImage(image) {
|
||||
ShareLink(
|
||||
item: shareURL,
|
||||
preview: SharePreview(String(localized: "share_week_title"), image: Image(uiImage: image))
|
||||
) {
|
||||
Label("share_week_button", systemImage: "square.and.arrow.up")
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.white.opacity(0.75))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
showPremiumFromExport = true
|
||||
} label: {
|
||||
Label("share_week_button", systemImage: "star.fill")
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.white.opacity(0.75))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.mealMoodMint.opacity(0.55))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.mealMoodCoral.opacity(0.4), lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private func copyFromPreviousWeek(currentPlan: WeekPlan, settings: AppSettings) {
|
||||
let previousPlan = fetchWeekPlan(for: viewModel.currentWeekStart.addingDays(-7))
|
||||
viewModel.copyFromPreviousWeek(
|
||||
currentPlan: currentPlan,
|
||||
previousPlan: previousPlan,
|
||||
settings: settings,
|
||||
allTags: tags,
|
||||
allDishes: dishes
|
||||
)
|
||||
}
|
||||
|
||||
private func renderWeekShareImage(plan: WeekPlan, settings: AppSettings) -> UIImage? {
|
||||
let renderer = ImageRenderer(content: WeekPlanShareView(plan: plan, settings: settings, dishes: dishes, tags: tags))
|
||||
renderer.proposedSize = ProposedViewSize(width: 2400, height: 1700)
|
||||
renderer.scale = 1
|
||||
return renderer.uiImage
|
||||
}
|
||||
|
||||
private func persistShareImage(_ image: UIImage) -> URL? {
|
||||
guard let data = image.pngData() else { return nil }
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("mealmood-week-plan.png")
|
||||
try? data.write(to: url, options: .atomic)
|
||||
return url
|
||||
}
|
||||
|
||||
private func fetchWeekPlan(for weekStartDate: Date) -> WeekPlan? {
|
||||
let descriptor = FetchDescriptor<WeekPlan>(
|
||||
predicate: #Predicate<WeekPlan> { plan in
|
||||
plan.weekStartDate == weekStartDate
|
||||
}
|
||||
)
|
||||
return try? context.fetch(descriptor).first
|
||||
}
|
||||
|
||||
private func evaluateReviewPrompt() {
|
||||
let descriptor = FetchDescriptor<WeekPlan>()
|
||||
guard let plans = try? context.fetch(descriptor) else { return }
|
||||
let completedWeeks = plans.filter { !$0.slots.isEmpty && $0.slots.allSatisfy { $0.dishId != nil } }.count
|
||||
ReviewPromptService.shared.considerPromptAfterWeekCompletion(completedWeeks: completedWeeks)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MonthlyHistoryView: View {
|
||||
let weekPlans: [WeekPlan]
|
||||
let currentWeekStart: Date
|
||||
let language: AppLanguage
|
||||
let onSelectWeek: (Date) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var monthCursor: Date
|
||||
|
||||
init(
|
||||
weekPlans: [WeekPlan],
|
||||
currentWeekStart: Date,
|
||||
language: AppLanguage,
|
||||
onSelectWeek: @escaping (Date) -> Void
|
||||
) {
|
||||
self.weekPlans = weekPlans
|
||||
self.currentWeekStart = currentWeekStart
|
||||
self.language = language
|
||||
self.onSelectWeek = onSelectWeek
|
||||
_monthCursor = State(initialValue: currentWeekStart.startOfMonth())
|
||||
}
|
||||
|
||||
private var locale: Locale {
|
||||
Locale(identifier: language.localeIdentifier)
|
||||
}
|
||||
|
||||
private var monthPlans: [WeekPlan] {
|
||||
let calendar = Calendar.current
|
||||
return weekPlans
|
||||
.filter {
|
||||
calendar.component(.year, from: $0.weekStartDate) == calendar.component(.year, from: monthCursor) &&
|
||||
calendar.component(.month, from: $0.weekStartDate) == calendar.component(.month, from: monthCursor)
|
||||
}
|
||||
.sorted { $0.weekStartDate > $1.weekStartDate }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 14) {
|
||||
monthHeader
|
||||
|
||||
if monthPlans.isEmpty {
|
||||
Text("history_month_empty")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.padding(.top, 24)
|
||||
} else {
|
||||
List(monthPlans, id: \.id) { plan in
|
||||
Button {
|
||||
onSelectWeek(plan.weekStartDate)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(weekLabel(for: plan.weekStartDate))
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Text(plan.slots.allSatisfy { $0.dishId != nil } ? String(localized: "history_week_complete") : String(localized: "history_week_incomplete"))
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
.background(Color.mealMoodBackground.ignoresSafeArea())
|
||||
.navigationTitle("history_title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("tag_selector_done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var monthHeader: some View {
|
||||
HStack {
|
||||
Button {
|
||||
monthCursor = monthCursor.addingMonths(-1)
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(monthCursor.monthYearLabel(locale: locale))
|
||||
.font(.mealMoodH3)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
monthCursor = monthCursor.addingMonths(1)
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func weekLabel(for startDate: Date) -> String {
|
||||
let endDate = startDate.addingDays(6)
|
||||
let dayFormatter = DateFormatter()
|
||||
dayFormatter.locale = locale
|
||||
dayFormatter.setLocalizedDateFormatFromTemplate("d")
|
||||
|
||||
let monthFormatter = DateFormatter()
|
||||
monthFormatter.locale = locale
|
||||
monthFormatter.setLocalizedDateFormatFromTemplate("MMM")
|
||||
|
||||
return "\(dayFormatter.string(from: startDate))-\(dayFormatter.string(from: endDate)) \(monthFormatter.string(from: endDate))"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WeekCalendarView: View {
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
let plan: WeekPlan
|
||||
let settings: AppSettings
|
||||
let dishes: [Dish]
|
||||
let tags: [Tag]
|
||||
@ObservedObject var viewModel: HomeViewModel
|
||||
var onTapEmptySlot: ((MealSlot) -> Void)?
|
||||
|
||||
private var dayRange: ClosedRange<Int> {
|
||||
settings.includeWeekends ? 0...6 : 0...4
|
||||
}
|
||||
|
||||
private var days: [Int] {
|
||||
Array(dayRange)
|
||||
}
|
||||
|
||||
private var mealTypes: [MealType] {
|
||||
switch settings.mealWindowsEnum {
|
||||
case .dinnerOnly: return [.dinner]
|
||||
case .lunchOnly: return [.lunch]
|
||||
case .both: return [.lunch, .dinner]
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if horizontalSizeClass == .regular {
|
||||
regularGridLayout
|
||||
} else {
|
||||
compactLayout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var compactLayout: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Array(days.enumerated()), id: \.element) { index, day in
|
||||
dayHeaderCell(day: day, width: 96, height: 24)
|
||||
if index < days.count - 1 {
|
||||
calendarDivider
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
calendarHorizontalDivider
|
||||
|
||||
ForEach(Array(mealTypes.enumerated()), id: \.element) { index, mealType in
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Array(days.enumerated()), id: \.element) { index, day in
|
||||
let slot = slotFor(day: day, mealType: mealType)
|
||||
draggableSlotView(slot: slot, mealType: mealType)
|
||||
.frame(width: 96)
|
||||
if index < days.count - 1 {
|
||||
calendarDivider
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if index < mealTypes.count - 1 {
|
||||
calendarHorizontalDivider
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
|
||||
private var regularGridLayout: some View {
|
||||
GeometryReader { proxy in
|
||||
let dayCount = CGFloat(dayRange.count)
|
||||
let spacing: CGFloat = 10
|
||||
let rowLabelWidth: CGFloat = 86
|
||||
let contentWidth = proxy.size.width - rowLabelWidth - (dayCount * spacing)
|
||||
let dayWidth = max(92, contentWidth / max(dayCount, 1))
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
Color.clear.frame(width: rowLabelWidth, height: 24)
|
||||
ForEach(Array(days.enumerated()), id: \.element) { index, day in
|
||||
dayHeaderCell(day: day, width: dayWidth, height: 24)
|
||||
if index < days.count - 1 {
|
||||
calendarDivider
|
||||
.padding(.horizontal, spacing / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
calendarHorizontalDivider
|
||||
|
||||
ForEach(Array(mealTypes.enumerated()), id: \.element) { rowIndex, mealType in
|
||||
HStack(spacing: 0) {
|
||||
mealTypeLabel(mealType)
|
||||
.frame(width: rowLabelWidth)
|
||||
.padding(.trailing, spacing)
|
||||
|
||||
ForEach(Array(days.enumerated()), id: \.element) { index, day in
|
||||
let slot = slotFor(day: day, mealType: mealType)
|
||||
draggableSlotView(slot: slot, mealType: mealType)
|
||||
.frame(width: dayWidth)
|
||||
if index < days.count - 1 {
|
||||
calendarDivider
|
||||
.padding(.horizontal, spacing / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rowIndex < mealTypes.count - 1 {
|
||||
calendarHorizontalDivider
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.frame(minHeight: CGFloat(mealTypes.count) * 142 + 54)
|
||||
}
|
||||
|
||||
private var calendarDivider: some View {
|
||||
Rectangle()
|
||||
.fill(Color.mealMoodTextSecondary.opacity(0.22))
|
||||
.frame(width: 1)
|
||||
}
|
||||
|
||||
private var calendarHorizontalDivider: some View {
|
||||
Rectangle()
|
||||
.fill(Color.mealMoodTextSecondary.opacity(0.22))
|
||||
.frame(height: 1)
|
||||
}
|
||||
|
||||
private func dayHeader(day: Int) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Text(LocalizedStringKey(dayKey(for: day)))
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
let dayDate = plan.weekStartDate.addingDays(day)
|
||||
Text("\(Calendar.current.component(.day, from: dayDate))")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(dayDate.isToday ? .mealMoodCoral : .mealMoodTextPrimary)
|
||||
.fontWeight(dayDate.isToday ? .bold : .regular)
|
||||
}
|
||||
}
|
||||
|
||||
private func dayHeaderCell(day: Int, width: CGFloat, height: CGFloat) -> some View {
|
||||
dayHeader(day: day)
|
||||
.frame(width: width, height: height)
|
||||
.background(Color.mealMoodCoral.opacity(0.28))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.mealMoodCoral.opacity(0.45), lineWidth: 1)
|
||||
)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
private func mealTypeLabel(_ mealType: MealType) -> some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: mealType.icon)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
Text(LocalizedStringKey(mealType.rawValue))
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
private func slotFor(day: Int, mealType: MealType) -> MealSlot? {
|
||||
plan.slots.first { $0.dayOfWeek == day && $0.mealType == mealType.rawValue }
|
||||
}
|
||||
|
||||
private func draggableSlotView(slot: MealSlot?, mealType: MealType) -> some View {
|
||||
slotView(slot: slot, mealType: mealType)
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
guard let payloadRaw = items.first,
|
||||
let payload = parseDropPayload(payloadRaw),
|
||||
let targetSlot = slot,
|
||||
viewModel.canEditCurrentWeek else { return false }
|
||||
|
||||
switch payload {
|
||||
case .dish(let dishId):
|
||||
guard let dish = dishes.first(where: { $0.id == dishId }) else { return false }
|
||||
|
||||
let isValid = AutocompleteEngine.validateDrop(
|
||||
dish: dish, slot: targetSlot, plan: plan,
|
||||
allTags: tags, allDishes: dishes
|
||||
)
|
||||
|
||||
if isValid {
|
||||
viewModel.assignDish(dish, to: targetSlot, plan: plan, settings: settings)
|
||||
return true
|
||||
}
|
||||
|
||||
viewModel.confirmInvalidDrop(dish, to: targetSlot, plan: plan, settings: settings)
|
||||
return true
|
||||
case .slot(let sourceSlotId):
|
||||
guard let sourceSlot = plan.slots.first(where: { $0.id == sourceSlotId }) else { return false }
|
||||
viewModel.moveOrSwapDish(
|
||||
from: sourceSlot,
|
||||
to: targetSlot,
|
||||
plan: plan,
|
||||
settings: settings,
|
||||
allTags: tags,
|
||||
allDishes: dishes
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func slotView(slot: MealSlot?, mealType: MealType) -> some View {
|
||||
if let slot = slot, let dishId = slot.dishId,
|
||||
let dish = dishes.first(where: { $0.id == dishId }) {
|
||||
let dishTags = tags.filter { dish.tagIds.contains($0.id) }
|
||||
FilledSlotView(
|
||||
dishName: dish.name,
|
||||
dishDescription: dish.descriptionText,
|
||||
tags: dishTags.map { (name: $0.localizedName(language: settings.languageEnum.resolved()), color: $0.color) },
|
||||
mealType: mealType,
|
||||
showsRuleWarning: slot.isRuleOverridden,
|
||||
onRemove: viewModel.canEditCurrentWeek ? {
|
||||
viewModel.removeDish(from: slot, plan: plan, settings: settings)
|
||||
} : nil
|
||||
)
|
||||
.draggable("slot:\(slot.id.uuidString)")
|
||||
} else if let slot = slot {
|
||||
EmptySlotView(mealType: mealType)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard viewModel.canEditCurrentWeek else { return }
|
||||
onTapEmptySlot?(slot)
|
||||
}
|
||||
} else {
|
||||
EmptySlotView(mealType: mealType)
|
||||
}
|
||||
}
|
||||
|
||||
private enum DropPayload {
|
||||
case dish(UUID)
|
||||
case slot(UUID)
|
||||
}
|
||||
|
||||
private func parseDropPayload(_ raw: String) -> DropPayload? {
|
||||
let parts = raw.split(separator: ":", maxSplits: 1).map(String.init)
|
||||
guard parts.count == 2, let id = UUID(uuidString: parts[1]) else { return nil }
|
||||
switch parts[0] {
|
||||
case "dish":
|
||||
return .dish(id)
|
||||
case "slot":
|
||||
return .slot(id)
|
||||
default:
|
||||
// Backward compatibility for older draggable payloads that only sent dish UUID.
|
||||
if let fallbackDishId = UUID(uuidString: raw) {
|
||||
return .dish(fallbackDishId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func dayKey(for dayIndex: Int) -> String {
|
||||
switch dayIndex {
|
||||
case 0: return "day_mon"
|
||||
case 1: return "day_tue"
|
||||
case 2: return "day_wed"
|
||||
case 3: return "day_thu"
|
||||
case 4: return "day_fri"
|
||||
case 5: return "day_sat"
|
||||
case 6: return "day_sun"
|
||||
default: return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WeekPlanShareView: View {
|
||||
let plan: WeekPlan
|
||||
let settings: AppSettings
|
||||
let dishes: [Dish]
|
||||
let tags: [Tag]
|
||||
|
||||
private var dayRange: ClosedRange<Int> {
|
||||
settings.includeWeekends ? 0...6 : 0...4
|
||||
}
|
||||
|
||||
private var mealTypes: [MealType] {
|
||||
switch settings.mealWindowsEnum {
|
||||
case .dinnerOnly: return [.dinner]
|
||||
case .lunchOnly: return [.lunch]
|
||||
case .both: return [.lunch, .dinner]
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(hex: "#FFF6F0"),
|
||||
Color(hex: "#F6FCFA")
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(alignment: .leading, spacing: 28) {
|
||||
HStack(spacing: 16) {
|
||||
AppIconPlaceholder(size: 72)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("MealMood")
|
||||
.font(.system(size: 44, weight: .bold))
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Text(plan.weekStartDate.formattedWeekRange())
|
||||
.font(.system(size: 26, weight: .medium))
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(Date.now.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
|
||||
Grid(horizontalSpacing: 10, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
gridHeaderCell("")
|
||||
.frame(minWidth: 220)
|
||||
ForEach(Array(dayRange), id: \.self) { day in
|
||||
gridHeaderCell(dayTitle(for: day))
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(mealTypes, id: \.self) { mealType in
|
||||
GridRow {
|
||||
gridMealCell(mealTypeLabel(mealType), icon: mealType.icon)
|
||||
.frame(minWidth: 220)
|
||||
ForEach(Array(dayRange), id: \.self) { day in
|
||||
gridDishCell(dishName(day: day, mealType: mealType))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("share_week_title")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
.padding(56)
|
||||
}
|
||||
.frame(width: 2400, height: 1700)
|
||||
}
|
||||
|
||||
private func dayTitle(for day: Int) -> String {
|
||||
let keys = ["day_mon", "day_tue", "day_wed", "day_thu", "day_fri", "day_sat", "day_sun"]
|
||||
let localizedDay = day >= 0 && day < keys.count ? NSLocalizedString(keys[day], comment: "") : ""
|
||||
let dayDate = plan.weekStartDate.addingDays(day)
|
||||
let dayNumber = Calendar.current.component(.day, from: dayDate)
|
||||
return "\(localizedDay) \(dayNumber)"
|
||||
}
|
||||
|
||||
private func mealTypeLabel(_ mealType: MealType) -> String {
|
||||
mealType == .lunch ? String(localized: "lunch") : String(localized: "dinner")
|
||||
}
|
||||
|
||||
private func dishName(day: Int, mealType: MealType) -> String {
|
||||
let slot = plan.slots.first { $0.dayOfWeek == day && $0.mealType == mealType.rawValue }
|
||||
guard let slot, let dishId = slot.dishId, let dish = dishes.first(where: { $0.id == dishId }) else {
|
||||
return "—"
|
||||
}
|
||||
return dish.name
|
||||
}
|
||||
|
||||
private func gridHeaderCell(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.frame(maxWidth: .infinity, minHeight: 92)
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color.white.opacity(0.95))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
private func gridMealCell(_ title: String, icon: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
Text(title)
|
||||
}
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.frame(maxWidth: .infinity, minHeight: 130)
|
||||
.padding(.horizontal, 14)
|
||||
.background(Color.white.opacity(0.95))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
private func gridDishCell(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.system(size: 26, weight: .medium))
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(3)
|
||||
.minimumScaleFactor(0.6)
|
||||
.frame(maxWidth: .infinity, minHeight: 130)
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color.white.opacity(0.92))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import SwiftUI
|
||||
import EventKit
|
||||
|
||||
struct CalendarStepView: View {
|
||||
@Binding var syncEnabled: Bool
|
||||
@Binding var selectedCalendarId: String?
|
||||
@Binding var lunchTime: Date
|
||||
@Binding var dinnerTime: Date
|
||||
var onNext: () -> Void
|
||||
var onSkip: () -> Void
|
||||
|
||||
@State private var availableCalendars: [EKCalendar] = []
|
||||
@State private var showPermissionAlert = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
Text("calendar_title")
|
||||
.font(.mealMoodH2)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 32)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
// Sync toggle
|
||||
HStack {
|
||||
Text("calendar_sync")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Spacer()
|
||||
Toggle("", isOn: $syncEnabled)
|
||||
.tint(.mealMoodCoral)
|
||||
.labelsHidden()
|
||||
.onChange(of: syncEnabled) { _, newValue in
|
||||
if newValue {
|
||||
Task {
|
||||
let granted = await CalendarService.shared.requestAccess()
|
||||
if granted {
|
||||
availableCalendars = CalendarService.shared.availableCalendars()
|
||||
} else {
|
||||
syncEnabled = false
|
||||
showPermissionAlert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(14)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
|
||||
)
|
||||
|
||||
if syncEnabled {
|
||||
// Calendar picker
|
||||
if !availableCalendars.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("calendar_select")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
|
||||
Picker("calendar_select", selection: $selectedCalendarId) {
|
||||
Text("calendar_select").tag(nil as String?)
|
||||
ForEach(availableCalendars, id: \.calendarIdentifier) { cal in
|
||||
Text(cal.title).tag(cal.calendarIdentifier as String?)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.padding(12)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Time pickers
|
||||
VStack(spacing: 12) {
|
||||
DatePicker("calendar_lunch_time", selection: $lunchTime, displayedComponents: .hourAndMinute)
|
||||
.font(.mealMoodBody)
|
||||
|
||||
DatePicker("calendar_dinner_time", selection: $dinnerTime, displayedComponents: .hourAndMinute)
|
||||
.font(.mealMoodBody)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(14)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Text("calendar_optional")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
|
||||
Spacer(minLength: 40)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
SecondaryButton(title: String(localized: "onboarding_skip"), action: {
|
||||
syncEnabled = false
|
||||
onSkip()
|
||||
})
|
||||
|
||||
PrimaryButton(title: String(localized: "onboarding_continue"), action: onNext)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
.alert("calendar_permission_title", isPresented: $showPermissionAlert) {
|
||||
Button("calendar_permission_settings") {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
Button("reset_cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("calendar_permission_message")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct FirstDishesStepView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@ObservedObject var viewModel: OnboardingViewModel
|
||||
let tags: [Tag]
|
||||
var onFinish: () -> Void
|
||||
|
||||
@State private var showTagSelector = false
|
||||
@Query(sort: \Tag.sortOrder) private var storedTags: [Tag]
|
||||
@Query private var allSettings: [AppSettings]
|
||||
|
||||
private var language: AppLanguage {
|
||||
(allSettings.first?.languageEnum ?? .system).resolved()
|
||||
}
|
||||
|
||||
private var availableTags: [Tag] {
|
||||
var seen = Set<UUID>()
|
||||
let merged = (tags + storedTags).filter { tag in
|
||||
seen.insert(tag.id).inserted
|
||||
}
|
||||
return merged.sorted(by: { $0.sortOrder < $1.sortOrder })
|
||||
}
|
||||
|
||||
private var defaultTagId: UUID? {
|
||||
availableTags.first(where: { $0.name.caseInsensitiveCompare("Verduras") == .orderedSame })?.id
|
||||
?? availableTags.first(where: { $0.nameEN.caseInsensitiveCompare("Vegetables") == .orderedSame })?.id
|
||||
?? availableTags.sorted(by: { $0.sortOrder < $1.sortOrder }).first?.id
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("first_dishes_title")
|
||||
.font(.mealMoodH2)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
|
||||
Text("first_dishes_subtitle")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 32)
|
||||
|
||||
// New dish form
|
||||
VStack(spacing: 12) {
|
||||
TextField("first_dishes_name_placeholder", text: $viewModel.newDishName)
|
||||
.font(.mealMoodBody)
|
||||
.padding(14)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
|
||||
)
|
||||
|
||||
// Selected tags
|
||||
if !viewModel.newDishTags.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(availableTags.filter { viewModel.newDishTags.contains($0.id) }) { tag in
|
||||
HStack(spacing: 4) {
|
||||
TagPill(name: tag.localizedName(language: language), color: tag.color)
|
||||
Button {
|
||||
viewModel.newDishTags.remove(tag.id)
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
ensureDefaultTagsIfNeeded()
|
||||
showTagSelector = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle")
|
||||
Text("first_dishes_add_tag")
|
||||
}
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if availableTags.isEmpty {
|
||||
Text("first_dishes_no_tags")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodError)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Button {
|
||||
ensureDefaultTagsIfNeeded()
|
||||
viewModel.addDish(defaultTagId: defaultTagId)
|
||||
HapticManager.shared.notification(type: .success)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "plus")
|
||||
Text("first_dishes_add_dish")
|
||||
}
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.white)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(viewModel.canAddDish ? Color.mealMoodCoral : Color.gray.opacity(0.3))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
.disabled(!viewModel.canAddDish)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
// Added dishes list
|
||||
if !viewModel.addedDishes.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("first_dishes_added")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
ForEach(Array(viewModel.addedDishes.enumerated()), id: \.offset) { index, dish in
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.mealMoodSuccess)
|
||||
|
||||
Text(dish.name)
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
|
||||
// Show tag pills
|
||||
HStack(spacing: 4) {
|
||||
ForEach(availableTags.filter { dish.tagIds.contains($0.id) }.prefix(2)) { tag in
|
||||
TagPill(name: tag.localizedName(language: language), color: tag.color)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
viewModel.removeDish(at: index)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.mealMoodError)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(12)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("first_dishes_suggestions")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
ForEach(suggestedDishes(), id: \.name) { suggestion in
|
||||
let isAdded = viewModel.addedDishes.contains(where: {
|
||||
$0.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == suggestion.name.lowercased()
|
||||
})
|
||||
Button {
|
||||
guard !isAdded else { return }
|
||||
viewModel.addSuggestedDish(
|
||||
name: suggestion.name,
|
||||
preferredTagNames: suggestion.tagHints,
|
||||
availableTags: availableTags
|
||||
)
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(suggestion.name)
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Text(isAdded ? "first_dishes_suggestion_added" : "first_dishes_suggestion_add")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: isAdded ? "checkmark.circle.fill" : "plus.circle.fill")
|
||||
.foregroundColor(isAdded ? .mealMoodSuccess : .mealMoodCoral)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
.disabled(isAdded)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 40)
|
||||
|
||||
PrimaryButton(
|
||||
title: String(localized: "onboarding_finish"),
|
||||
icon: "🎉",
|
||||
action: {
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
HapticManager.shared.notification(type: .success)
|
||||
onFinish()
|
||||
},
|
||||
isEnabled: viewModel.addedDishes.count >= 2
|
||||
)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showTagSelector) {
|
||||
TagSelectorSheet(
|
||||
tags: availableTags,
|
||||
selectedTagIds: $viewModel.newDishTags
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
ensureDefaultTagsIfNeeded()
|
||||
}
|
||||
.onChange(of: storedTags.count) { _, newCount in
|
||||
if newCount == 0 {
|
||||
ensureDefaultTagsIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func suggestedDishes() -> [(name: String, tagHints: [String])] {
|
||||
if language == .english {
|
||||
return [
|
||||
(name: "Roast chicken", tagHints: ["Meat"]),
|
||||
(name: "Lentil stew", tagHints: ["Legumes"]),
|
||||
(name: "Veggie pasta", tagHints: ["Pasta/Rice", "Vegetables"])
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
(name: "Pollo asado", tagHints: ["Carne"]),
|
||||
(name: "Lentejas estofadas", tagHints: ["Legumbres"]),
|
||||
(name: "Pasta con verduras", tagHints: ["Pasta/Arroz", "Verduras"])
|
||||
]
|
||||
}
|
||||
|
||||
private func ensureDefaultTagsIfNeeded() {
|
||||
let descriptor = FetchDescriptor<Tag>()
|
||||
if (try? context.fetch(descriptor))?.isEmpty ?? true {
|
||||
DefaultDataService.createDefaultTags(context: context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MealWindowsStepView: View {
|
||||
@Binding var selection: MealWindows
|
||||
var onNext: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
|
||||
Text("meal_windows_title")
|
||||
.font(.mealMoodH2)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
ForEach(MealWindows.allCases, id: \.self) { option in
|
||||
SelectionCard(
|
||||
icon: option.icon,
|
||||
title: optionTitle(option),
|
||||
isSelected: selection == option
|
||||
) {
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
selection = option
|
||||
}
|
||||
HapticManager.shared.selection()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Spacer()
|
||||
|
||||
PrimaryButton(title: String(localized: "onboarding_continue"), action: onNext)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
|
||||
private func optionTitle(_ option: MealWindows) -> String {
|
||||
switch option {
|
||||
case .dinnerOnly: return "🌙 \(String(localized: "meal_windows_dinner_only"))"
|
||||
case .lunchOnly: return "☀️ \(String(localized: "meal_windows_lunch_only"))"
|
||||
case .both: return "🌞🌙 \(String(localized: "meal_windows_both"))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SelectionCard: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Spacer()
|
||||
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundColor(isSelected ? .mealMoodCoral : Color(hex: "#C4C4C4"))
|
||||
.font(.system(size: 22))
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(14)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(isSelected ? Color.mealMoodCoral : Color(hex: "#F0F0F0"), lineWidth: isSelected ? 2 : 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct OnboardingView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@StateObject private var viewModel = OnboardingViewModel()
|
||||
@Query(sort: \Tag.sortOrder) private var tags: [Tag]
|
||||
var onComplete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.mealMoodBackground.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Progress indicator
|
||||
if viewModel.currentStep > 0 {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(1..<viewModel.totalSteps, id: \.self) { step in
|
||||
Capsule()
|
||||
.fill(step <= viewModel.currentStep ? Color.mealMoodCoral : Color(hex: "#E0E0E0"))
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
TabView(selection: $viewModel.currentStep) {
|
||||
WelcomeStepView(onNext: { viewModel.nextStep() })
|
||||
.tag(0)
|
||||
|
||||
MealWindowsStepView(
|
||||
selection: $viewModel.selectedMealWindows,
|
||||
onNext: { viewModel.nextStep() }
|
||||
)
|
||||
.tag(1)
|
||||
|
||||
WeekendsStepView(
|
||||
includeWeekends: $viewModel.includeWeekends,
|
||||
onNext: { viewModel.nextStep() }
|
||||
)
|
||||
.tag(2)
|
||||
|
||||
CalendarStepView(
|
||||
syncEnabled: $viewModel.syncCalendar,
|
||||
selectedCalendarId: $viewModel.selectedCalendarId,
|
||||
lunchTime: $viewModel.lunchTime,
|
||||
dinnerTime: $viewModel.dinnerTime,
|
||||
onNext: { viewModel.nextStep() },
|
||||
onSkip: { viewModel.nextStep() }
|
||||
)
|
||||
.tag(3)
|
||||
|
||||
FirstDishesStepView(
|
||||
viewModel: viewModel,
|
||||
tags: tags,
|
||||
onFinish: {
|
||||
if viewModel.completeOnboarding(context: context) {
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
)
|
||||
.tag(4)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.animation(.easeInOut(duration: 0.3), value: viewModel.currentStep)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
ensureDefaultTagsIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureDefaultTagsIfNeeded() {
|
||||
let descriptor = FetchDescriptor<Tag>()
|
||||
if (try? context.fetch(descriptor))?.isEmpty ?? true {
|
||||
DefaultDataService.createDefaultTags(context: context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WeekendsStepView: View {
|
||||
@Binding var includeWeekends: Bool
|
||||
var onNext: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
|
||||
Text("weekends_title")
|
||||
.font(.mealMoodH2)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("weekends_toggle")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: $includeWeekends)
|
||||
.tint(.mealMoodCoral)
|
||||
.labelsHidden()
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(14)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Text("weekends_note")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
PrimaryButton(title: String(localized: "onboarding_continue"), action: onNext)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WelcomeStepView: View {
|
||||
var onNext: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
|
||||
AppIconPlaceholder(size: 120)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Text("onboarding_welcome_title")
|
||||
.font(.mealMoodH1)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
|
||||
Text("onboarding_welcome_subtitle")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Image(systemName: "figure.2.and.child.holdinghands")
|
||||
.font(.system(size: 80))
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.mealMoodCoral, .mealMoodMint],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.padding(.vertical, 20)
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
BenefitRow(icon: "face.smiling", text: String(localized: "onboarding_welcome_benefit1"))
|
||||
BenefitRow(icon: "scalemass", text: String(localized: "onboarding_welcome_benefit2"))
|
||||
BenefitRow(icon: "calendar", text: String(localized: "onboarding_welcome_benefit3"))
|
||||
}
|
||||
.padding(.horizontal, 40)
|
||||
|
||||
Spacer()
|
||||
|
||||
PrimaryButton(title: String(localized: "onboarding_start"), icon: "✨", action: onNext)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BenefitRow: View {
|
||||
let icon: String
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
.frame(width: 28)
|
||||
|
||||
Text(text)
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
|
||||
struct PremiumView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@StateObject private var storeManager = StoreManager()
|
||||
@State private var purchaseStatusMessageKey: String?
|
||||
|
||||
let settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.mealMoodBackground.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
VStack(spacing: 16) {
|
||||
AppIconPlaceholder(size: 84)
|
||||
|
||||
Text("premium_title")
|
||||
.font(.mealMoodH1)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
|
||||
Text("premium_subtitle")
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.top, 28)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
BenefitItem(text: String(localized: "premium_no_ads"))
|
||||
BenefitItem(text: String(localized: "premium_unlimited_dishes"))
|
||||
BenefitItem(text: String(localized: "premium_advanced_rules"))
|
||||
BenefitItem(text: String(localized: "premium_future_weeks"))
|
||||
BenefitItem(text: String(localized: "premium_share_week"))
|
||||
BenefitItem(text: String(localized: "premium_family_sharing"))
|
||||
}
|
||||
.padding(20)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(16)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
if settings.isPremium {
|
||||
Label("premium_active", systemImage: "checkmark.seal.fill")
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodSuccess)
|
||||
.padding(.horizontal, 24)
|
||||
} else if let monthly = storeManager.monthlyProduct {
|
||||
PlanCard(
|
||||
title: String(localized: "premium_monthly"),
|
||||
price: monthly.displayPrice + " / " + String(localized: "premium_month"),
|
||||
caption: String(localized: "premium_price_note"),
|
||||
isLoading: storeManager.isLoading
|
||||
) {
|
||||
Task { await purchase(monthly) }
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
} else {
|
||||
let fallbackText = fallbackContent(for: storeManager.productLoadState)
|
||||
|
||||
PlanCard(
|
||||
title: String(localized: "premium_monthly"),
|
||||
price: fallbackText.price,
|
||||
caption: fallbackText.hint,
|
||||
buttonTitle: String(localized: "premium_retry_products"),
|
||||
isLoading: storeManager.isLoadingProducts
|
||||
) {
|
||||
Task { await storeManager.loadProducts() }
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
#if DEBUG
|
||||
let hasStoreKitFileInBundle = Bundle.main.url(forResource: "MealMood", withExtension: "storekit") != nil
|
||||
let hasStoreKitLaunchArgument = ProcessInfo.processInfo.arguments
|
||||
.contains { $0.localizedCaseInsensitiveContains("storekit") }
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Debug")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
Text("StoreKit file in bundle: \(hasStoreKitFileInBundle ? "yes" : "no")")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
Text("StoreKit launch arg: \(hasStoreKitLaunchArgument ? "yes" : "no")")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
Text("Bundle: \(Bundle.main.bundleIdentifier ?? "-")")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
Text("IDs: \(storeManager.debugProductIds.joined(separator: ", "))")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
Text("Loaded: \(storeManager.debugLoadedProductIds.joined(separator: ", "))")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
Text("Loaded detail: \(storeManager.debugLoadedProducts.joined(separator: ", "))")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
#endif
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await restorePurchases() }
|
||||
} label: {
|
||||
Text("premium_restore")
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
|
||||
Text("premium_terms_note")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("settings_premium")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await storeManager.loadProducts()
|
||||
}
|
||||
.onChange(of: storeManager.isPremium) { _, isPremium in
|
||||
if settings.isPremium != isPremium {
|
||||
settings.isPremium = isPremium
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
.alert("premium_title", isPresented: Binding(
|
||||
get: { purchaseStatusMessageKey != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented { purchaseStatusMessageKey = nil }
|
||||
}
|
||||
)) {
|
||||
Button("dish_delete_blocked_ok", role: .cancel) {}
|
||||
} message: {
|
||||
Text(LocalizedStringKey(purchaseStatusMessageKey ?? ""))
|
||||
}
|
||||
}
|
||||
|
||||
private func purchase(_ product: Product) async {
|
||||
let result = await storeManager.purchase(product)
|
||||
switch result {
|
||||
case .success:
|
||||
settings.isPremium = true
|
||||
try? context.save()
|
||||
case .pending:
|
||||
purchaseStatusMessageKey = "premium_purchase_pending"
|
||||
case .cancelled:
|
||||
purchaseStatusMessageKey = "premium_purchase_cancelled"
|
||||
case .failed:
|
||||
purchaseStatusMessageKey = "premium_purchase_failed"
|
||||
}
|
||||
}
|
||||
|
||||
private func restorePurchases() async {
|
||||
await storeManager.restorePurchases()
|
||||
settings.isPremium = storeManager.isPremium
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
private func fallbackContent(for state: StoreManager.ProductLoadState) -> (price: String, hint: String) {
|
||||
switch state {
|
||||
case .timedOut:
|
||||
return (
|
||||
String(localized: "premium_loading_timeout"),
|
||||
String(localized: "premium_loading_timeout_hint")
|
||||
)
|
||||
case .notFound:
|
||||
return (
|
||||
String(localized: "premium_products_not_found"),
|
||||
String(localized: "premium_products_not_found_hint")
|
||||
)
|
||||
case .failed:
|
||||
return (
|
||||
String(localized: "premium_loading_failed"),
|
||||
String(localized: "premium_loading_failed_hint")
|
||||
)
|
||||
case .idle, .loading, .loaded:
|
||||
return (
|
||||
String(localized: "premium_loading_products"),
|
||||
String(localized: "premium_loading_products_hint")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BenefitItem: View {
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.mealMoodSuccess)
|
||||
.font(.system(size: 18))
|
||||
Text(text)
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PlanCard: View {
|
||||
let title: String
|
||||
let price: String
|
||||
let caption: String
|
||||
var buttonTitle: String? = nil
|
||||
var isLoading: Bool = false
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.mealMoodH3)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Text(price)
|
||||
.font(.mealMoodBody)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
Text(caption)
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
PrimaryButton(
|
||||
title: isLoading
|
||||
? String(localized: "premium_processing")
|
||||
: (buttonTitle ?? String(localized: "premium_subscribe")),
|
||||
action: action,
|
||||
isEnabled: !isLoading
|
||||
)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(16)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import EventKit
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@Query private var allSettings: [AppSettings]
|
||||
@StateObject private var viewModel = SettingsViewModel()
|
||||
@State private var showResetAllDataAlert = false
|
||||
|
||||
private var settings: AppSettings? { allSettings.first }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.mealMoodBackground.ignoresSafeArea()
|
||||
|
||||
if let settings = settings {
|
||||
settingsContent(settings: settings)
|
||||
}
|
||||
}
|
||||
.navigationTitle("settings_title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarBackground(Color.mealMoodBackground, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.environment(\.isEnabled, true)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func settingsContent(settings: AppSettings) -> some View {
|
||||
List {
|
||||
// Planning section
|
||||
Section {
|
||||
Picker("settings_meal_windows", selection: Binding(
|
||||
get: { settings.mealWindowsEnum },
|
||||
set: { settings.mealWindowsEnum = $0 }
|
||||
)) {
|
||||
Text("meal_windows_dinner_only").tag(MealWindows.dinnerOnly)
|
||||
Text("meal_windows_lunch_only").tag(MealWindows.lunchOnly)
|
||||
Text("meal_windows_both").tag(MealWindows.both)
|
||||
}
|
||||
|
||||
Toggle("settings_include_weekends", isOn: Binding(
|
||||
get: { settings.includeWeekends },
|
||||
set: { settings.includeWeekends = $0 }
|
||||
))
|
||||
.tint(.mealMoodCoral)
|
||||
} header: {
|
||||
Label("settings_planning", systemImage: "fork.knife")
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
|
||||
// Calendar section
|
||||
Section {
|
||||
Toggle("settings_icloud_sync", isOn: Binding(
|
||||
get: { settings.iCloudSyncEnabledResolved },
|
||||
set: { settings.iCloudSyncEnabledResolved = $0 }
|
||||
))
|
||||
.tint(.mealMoodCoral)
|
||||
|
||||
Toggle("settings_sync", isOn: Binding(
|
||||
get: { settings.syncEnabled },
|
||||
set: { newValue in
|
||||
if newValue {
|
||||
Task {
|
||||
let granted = await viewModel.requestCalendarAccess()
|
||||
if granted {
|
||||
settings.syncEnabled = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
settings.syncEnabled = false
|
||||
}
|
||||
}
|
||||
))
|
||||
.tint(.mealMoodCoral)
|
||||
|
||||
if settings.syncEnabled {
|
||||
Picker("settings_sync_mode", selection: Binding(
|
||||
get: { settings.syncModeEnum },
|
||||
set: { settings.syncModeEnum = $0 }
|
||||
)) {
|
||||
ForEach(CalendarSyncMode.allCases, id: \.self) { mode in
|
||||
Text(LocalizedStringKey(mode.localizedKey)).tag(mode)
|
||||
}
|
||||
}
|
||||
|
||||
if !viewModel.availableCalendars.isEmpty {
|
||||
Picker("calendar_select", selection: Binding(
|
||||
get: { settings.calendarId },
|
||||
set: { settings.calendarId = $0 }
|
||||
)) {
|
||||
Text("calendar_select").tag(nil as String?)
|
||||
ForEach(viewModel.availableCalendars, id: \.calendarIdentifier) { cal in
|
||||
Text(cal.title).tag(cal.calendarIdentifier as String?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DatePicker("settings_lunch_time", selection: Binding(
|
||||
get: { settings.lunchTime },
|
||||
set: { settings.lunchTime = $0 }
|
||||
), displayedComponents: .hourAndMinute)
|
||||
|
||||
DatePicker("settings_dinner_time", selection: Binding(
|
||||
get: { settings.dinnerTime },
|
||||
set: { settings.dinnerTime = $0 }
|
||||
), displayedComponents: .hourAndMinute)
|
||||
|
||||
Picker("settings_event_duration", selection: Binding(
|
||||
get: { settings.eventDuration },
|
||||
set: { settings.eventDuration = $0 }
|
||||
)) {
|
||||
Text("duration_30").tag(30)
|
||||
Text("duration_60").tag(60)
|
||||
Text("duration_90").tag(90)
|
||||
Text("duration_120").tag(120)
|
||||
}
|
||||
|
||||
TextField("settings_event_prefix", text: Binding(
|
||||
get: { settings.eventPrefix },
|
||||
set: { settings.eventPrefix = $0 }
|
||||
))
|
||||
|
||||
Picker("settings_reminder", selection: Binding(
|
||||
get: { settings.reminderMinutesBefore },
|
||||
set: { settings.reminderMinutesBefore = $0 }
|
||||
)) {
|
||||
Text("settings_reminder_none").tag(nil as Int?)
|
||||
Text("reminder_30").tag(30 as Int?)
|
||||
Text("reminder_60").tag(60 as Int?)
|
||||
Text("reminder_120").tag(120 as Int?)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Label("settings_calendar", systemImage: "calendar")
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
|
||||
// Tags section
|
||||
Section {
|
||||
NavigationLink(destination: TagListView()) {
|
||||
Text("settings_manage_tags")
|
||||
}
|
||||
} header: {
|
||||
Label("settings_tags", systemImage: "tag")
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
|
||||
// Language section
|
||||
Section {
|
||||
Picker("settings_language", selection: Binding(
|
||||
get: { settings.languageEnum },
|
||||
set: { settings.languageEnum = $0 }
|
||||
)) {
|
||||
ForEach(AppLanguage.allCases, id: \.self) { lang in
|
||||
Text(lang.displayName).tag(lang)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Label("settings_language", systemImage: "globe")
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
|
||||
// Premium section
|
||||
Section {
|
||||
HStack {
|
||||
Text("settings_premium_status")
|
||||
Spacer()
|
||||
Text(settings.isPremium ? "Premium" : "Free")
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
|
||||
NavigationLink(destination: PremiumView(settings: settings)) {
|
||||
Text("settings_remove_ads")
|
||||
}
|
||||
} header: {
|
||||
Label("settings_premium", systemImage: "star")
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
|
||||
Section {
|
||||
Button {
|
||||
ReviewPromptService.shared.requestFromSettings()
|
||||
} label: {
|
||||
Label("settings_rate_app", systemImage: "star.bubble")
|
||||
}
|
||||
|
||||
Link(destination: URL(string: "https://mealmood.app")!) {
|
||||
Label("settings_website", systemImage: "safari")
|
||||
}
|
||||
|
||||
Link(destination: URL(string: "mailto:support@mealmood.app")!) {
|
||||
Label("settings_support", systemImage: "envelope")
|
||||
}
|
||||
} header: {
|
||||
Label("settings_about", systemImage: "info.circle")
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
showResetAllDataAlert = true
|
||||
} label: {
|
||||
Label("settings_reset_all_data", systemImage: "trash")
|
||||
}
|
||||
} header: {
|
||||
Label("settings_danger_zone", systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.listStyle(.insetGrouped)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.tint(.mealMoodCoral)
|
||||
.onAppear {
|
||||
if settings.syncEnabled {
|
||||
viewModel.loadCalendars()
|
||||
}
|
||||
}
|
||||
.alert("calendar_permission_title", isPresented: $viewModel.showCalendarPermissionAlert) {
|
||||
Button("calendar_permission_settings") { viewModel.openSystemSettings() }
|
||||
Button("reset_cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("calendar_permission_message")
|
||||
}
|
||||
.alert("settings_reset_all_data_title", isPresented: $showResetAllDataAlert) {
|
||||
Button("reset_cancel", role: .cancel) {}
|
||||
Button("settings_reset_all_data_confirm", role: .destructive) {
|
||||
viewModel.resetAllData(context: context)
|
||||
}
|
||||
} message: {
|
||||
Text("settings_reset_all_data_message")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct TagListView: View {
|
||||
@Query(sort: \Tag.sortOrder) private var tags: [Tag]
|
||||
@Query private var allSettings: [AppSettings]
|
||||
@State private var selectedTag: Tag?
|
||||
|
||||
private var language: AppLanguage {
|
||||
(allSettings.first?.languageEnum ?? .system).resolved()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.mealMoodBackground.ignoresSafeArea()
|
||||
|
||||
List {
|
||||
ForEach(tags) { tag in
|
||||
Button {
|
||||
selectedTag = tag
|
||||
} label: {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: tag.color))
|
||||
.frame(width: 14, height: 14)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(tag.localizedName(language: language))
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
|
||||
Text(tag.localizedRulesDescription(language: language))
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.mealMoodSurface)
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.navigationTitle("tags_title")
|
||||
.sheet(item: $selectedTag) { tag in
|
||||
TagRulesEditView(tag: tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct TagRulesEditView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var viewModel = TagViewModel()
|
||||
@Query private var allSettings: [AppSettings]
|
||||
@State private var showPremium = false
|
||||
let tag: Tag
|
||||
|
||||
private var language: AppLanguage {
|
||||
(allSettings.first?.languageEnum ?? .system).resolved()
|
||||
}
|
||||
|
||||
private var isPremium: Bool {
|
||||
allSettings.first?.isPremium ?? false
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color.mealMoodBackground.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Tag header
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(Color(hex: tag.color))
|
||||
.frame(width: 20, height: 20)
|
||||
Text(tag.localizedName(language: language))
|
||||
.font(.mealMoodH2)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
// Max per week
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("tags_max_per_week")
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Spacer()
|
||||
Toggle("", isOn: $viewModel.useMaxLimit)
|
||||
.tint(.mealMoodCoral)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
if viewModel.useMaxLimit {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(1...7, id: \.self) { num in
|
||||
Button {
|
||||
viewModel.maxPerWeek = num
|
||||
HapticManager.shared.selection()
|
||||
} label: {
|
||||
Text("\(num)")
|
||||
.font(.mealMoodSmall)
|
||||
.fontWeight(.semibold)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(
|
||||
viewModel.maxPerWeek == num
|
||||
? Color.mealMoodCoral
|
||||
: Color.mealMoodSurface
|
||||
)
|
||||
.foregroundColor(
|
||||
viewModel.maxPerWeek == num
|
||||
? .white
|
||||
: .mealMoodTextPrimary
|
||||
)
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color(hex: "#E0E0E0"), lineWidth: viewModel.maxPerWeek == num ? 0 : 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("\(String(localized: "tags_max_per_week")): \(viewModel.maxPerWeek ?? 3)")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(14)
|
||||
|
||||
// No consecutive toggle
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("tags_no_consecutive")
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Text("tags_no_consecutive_desc")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: $viewModel.noConsecutive)
|
||||
.tint(.mealMoodCoral)
|
||||
.labelsHidden()
|
||||
.disabled(!isPremium)
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(14)
|
||||
|
||||
// No duplicate in day toggle
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("tags_no_duplicate")
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Text("tags_no_duplicate_desc")
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: $viewModel.noDuplicateInDay)
|
||||
.tint(.mealMoodCoral)
|
||||
.labelsHidden()
|
||||
.disabled(!isPremium)
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(14)
|
||||
|
||||
// Meal type restriction
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("tags_restriction")
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
|
||||
Picker("Restricción", selection: $viewModel.mealTypeRestriction) {
|
||||
Text("tags_no_restriction").tag(nil as String?)
|
||||
Text("tags_lunch_only").tag("lunch" as String?)
|
||||
Text("tags_dinner_only").tag("dinner" as String?)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.disabled(!isPremium)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(14)
|
||||
|
||||
if !isPremium {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "star.circle")
|
||||
Text("premium_limit_rules")
|
||||
Spacer()
|
||||
Button("settings_remove_ads") {
|
||||
showPremium = true
|
||||
}
|
||||
}
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
.padding(12)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
.navigationTitle(tag.localizedName(language: language))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
viewModel.save()
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "checkmark")
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.loadTag(tag)
|
||||
}
|
||||
.sheet(isPresented: $showPremium) {
|
||||
if let settings = allSettings.first {
|
||||
NavigationStack { PremiumView(settings: settings) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import XCTest
|
||||
@testable import MealMood
|
||||
|
||||
final class AutocompleteEngineTests: XCTestCase {
|
||||
|
||||
func testValidateDropRejectsConsecutiveTag() {
|
||||
let tag = Tag(
|
||||
name: "Carne",
|
||||
nameEN: "Meat",
|
||||
color: "#E74C3C",
|
||||
maxPerWeek: nil,
|
||||
noConsecutive: true,
|
||||
noDuplicateInDay: false
|
||||
)
|
||||
|
||||
let dishA = Dish(name: "Pollo", tagIds: [tag.id])
|
||||
let dishB = Dish(name: "Ternera", tagIds: [tag.id])
|
||||
|
||||
let plan = WeekPlan(weekStartDate: Date().startOfWeek())
|
||||
let mondayDinner = MealSlot(dayOfWeek: 0, mealType: MealType.dinner.rawValue, dishId: dishA.id)
|
||||
let tuesdayDinner = MealSlot(dayOfWeek: 1, mealType: MealType.dinner.rawValue, dishId: nil)
|
||||
plan.slots = [mondayDinner, tuesdayDinner]
|
||||
|
||||
let isValid = AutocompleteEngine.validateDrop(
|
||||
dish: dishB,
|
||||
slot: tuesdayDinner,
|
||||
plan: plan,
|
||||
allTags: [tag],
|
||||
allDishes: [dishA, dishB]
|
||||
)
|
||||
|
||||
XCTAssertFalse(isValid)
|
||||
}
|
||||
|
||||
func testAutocompleteLeavesSlotEmptyWhenMaxPerWeekReached() {
|
||||
let tag = Tag(
|
||||
name: "Pescado",
|
||||
nameEN: "Fish",
|
||||
color: "#3498DB",
|
||||
maxPerWeek: 1,
|
||||
noConsecutive: false,
|
||||
noDuplicateInDay: false
|
||||
)
|
||||
|
||||
let fishDish = Dish(name: "Salmon", tagIds: [tag.id])
|
||||
let plan = WeekPlan(weekStartDate: Date().startOfWeek())
|
||||
|
||||
let mondayDinner = MealSlot(dayOfWeek: 0, mealType: MealType.dinner.rawValue, dishId: fishDish.id)
|
||||
let tuesdayDinner = MealSlot(dayOfWeek: 1, mealType: MealType.dinner.rawValue, dishId: nil)
|
||||
plan.slots = [mondayDinner, tuesdayDinner]
|
||||
|
||||
let result = AutocompleteEngine.autocomplete(
|
||||
emptySlots: [tuesdayDinner],
|
||||
currentPlan: plan,
|
||||
allDishes: [fishDish],
|
||||
allTags: [tag]
|
||||
)
|
||||
|
||||
XCTAssertEqual(result.unfilledCount, 1)
|
||||
XCTAssertNil(tuesdayDinner.dishId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import XCTest
|
||||
@testable import MealMood
|
||||
|
||||
final class DateHelpersTests: XCTestCase {
|
||||
|
||||
func testStartOfWeekReturnsMonday() {
|
||||
var components = DateComponents()
|
||||
components.year = 2026
|
||||
components.month = 2
|
||||
components.day = 18 // Wednesday
|
||||
let calendar = Calendar(identifier: .gregorian)
|
||||
let input = calendar.date(from: components)!
|
||||
|
||||
let start = input.startOfWeek()
|
||||
let weekday = calendar.component(.weekday, from: start)
|
||||
|
||||
XCTAssertEqual(weekday, 2) // Monday in Gregorian calendar
|
||||
}
|
||||
|
||||
func testCombineDateAndTime() {
|
||||
let calendar = Calendar(identifier: .gregorian)
|
||||
|
||||
let date = calendar.date(from: DateComponents(year: 2026, month: 2, day: 20))!
|
||||
let time = calendar.date(from: DateComponents(hour: 21, minute: 30))!
|
||||
|
||||
let combined = combineDateAndTime(date: date, time: time)
|
||||
let parts = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: combined)
|
||||
|
||||
XCTAssertEqual(parts.year, 2026)
|
||||
XCTAssertEqual(parts.month, 2)
|
||||
XCTAssertEqual(parts.day, 20)
|
||||
XCTAssertEqual(parts.hour, 21)
|
||||
XCTAssertEqual(parts.minute, 30)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import XCTest
|
||||
@testable import MealMood
|
||||
|
||||
@MainActor
|
||||
final class OnboardingViewModelTests: XCTestCase {
|
||||
|
||||
func testAddDishAllowsNameOnlyAndUsesDefaultTag() {
|
||||
let vm = OnboardingViewModel()
|
||||
vm.newDishName = "Test dish"
|
||||
let fallbackId = UUID()
|
||||
|
||||
vm.addDish(defaultTagId: fallbackId)
|
||||
|
||||
XCTAssertEqual(vm.addedDishes.count, 1)
|
||||
XCTAssertEqual(vm.addedDishes.first?.name, "Test dish")
|
||||
XCTAssertEqual(vm.addedDishes.first?.tagIds, [fallbackId])
|
||||
}
|
||||
|
||||
func testAddDishPreventsDuplicateNames() {
|
||||
let vm = OnboardingViewModel()
|
||||
vm.newDishName = "Lentils"
|
||||
vm.addDish(defaultTagId: UUID())
|
||||
|
||||
vm.newDishName = " lentils "
|
||||
vm.addDish(defaultTagId: UUID())
|
||||
|
||||
XCTAssertEqual(vm.addedDishes.count, 1)
|
||||
}
|
||||
|
||||
func testSuggestedDishFallsBackToFirstTagWhenNoNameMatch() {
|
||||
let vm = OnboardingViewModel()
|
||||
let fallback = Tag(name: "Carne", nameEN: "Meat", color: "#E74C3C", sortOrder: 0)
|
||||
let other = Tag(name: "Pescado", nameEN: "Fish", color: "#3498DB", sortOrder: 1)
|
||||
|
||||
vm.addSuggestedDish(
|
||||
name: "Roast chicken",
|
||||
preferredTagNames: ["Unknown tag"],
|
||||
availableTags: [other, fallback]
|
||||
)
|
||||
|
||||
XCTAssertEqual(vm.addedDishes.count, 1)
|
||||
XCTAssertEqual(vm.addedDishes.first?.tagIds, [fallback.id])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import XCTest
|
||||
@testable import MealMood
|
||||
|
||||
final class TagLocalizationTests: XCTestCase {
|
||||
|
||||
func testLocalizedNameUsesEnglishWhenAvailable() {
|
||||
let tag = Tag(
|
||||
name: "Carne",
|
||||
nameEN: "Meat",
|
||||
color: "#E74C3C"
|
||||
)
|
||||
|
||||
XCTAssertEqual(tag.localizedName(language: .spanish), "Carne")
|
||||
XCTAssertEqual(tag.localizedName(language: .english), "Meat")
|
||||
}
|
||||
|
||||
func testLocalizedRulesDescriptionChangesByLanguage() {
|
||||
let tag = Tag(
|
||||
name: "Carne",
|
||||
nameEN: "Meat",
|
||||
color: "#E74C3C",
|
||||
maxPerWeek: 3,
|
||||
noConsecutive: true,
|
||||
noDuplicateInDay: true,
|
||||
mealTypeRestriction: "dinner"
|
||||
)
|
||||
|
||||
let es = tag.localizedRulesDescription(language: .spanish)
|
||||
let en = tag.localizedRulesDescription(language: .english)
|
||||
|
||||
XCTAssertTrue(es.contains("Max 3/semana"))
|
||||
XCTAssertTrue(es.contains("solo cena"))
|
||||
XCTAssertTrue(en.contains("Max 3/week"))
|
||||
XCTAssertTrue(en.contains("dinner only"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"appPolicies" : {
|
||||
"eula" : "",
|
||||
"policies" : [
|
||||
{
|
||||
"locale" : "en_US",
|
||||
"policyText" : "",
|
||||
"policyURL" : ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"identifier" : "767351D8",
|
||||
"nonRenewingSubscriptions" : [
|
||||
|
||||
],
|
||||
"products" : [
|
||||
|
||||
],
|
||||
"settings" : {
|
||||
"_applicationInternalID" : "6759255553",
|
||||
"_askToBuyEnabled" : false,
|
||||
"_billingGracePeriodEnabled" : false,
|
||||
"_billingIssuesEnabled" : false,
|
||||
"_developerTeamID" : "2825Q76T7H",
|
||||
"_disableDialogs" : false,
|
||||
"_failTransactionsEnabled" : false,
|
||||
"_lastSynchronizedDate" : 793022741.80027604,
|
||||
"_locale" : "en_US",
|
||||
"_renewalBillingIssuesEnabled" : false,
|
||||
"_storefront" : "USA",
|
||||
"_storeKitErrors" : [
|
||||
|
||||
],
|
||||
"_timeRate" : 0
|
||||
},
|
||||
"subscriptionGroups" : [
|
||||
|
||||
],
|
||||
"version" : {
|
||||
"major" : 4,
|
||||
"minor" : 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
name: MealMood
|
||||
options:
|
||||
bundleIdPrefix: com.alexandrevazquez
|
||||
createIntermediateGroups: true
|
||||
developmentLanguage: en
|
||||
packages:
|
||||
GoogleMobileAds:
|
||||
url: https://github.com/googleads/swift-package-manager-google-mobile-ads.git
|
||||
from: 11.13.0
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: 6.0
|
||||
IPHONEOS_DEPLOYMENT_TARGET: 17.0
|
||||
targets:
|
||||
MealMood:
|
||||
type: application
|
||||
platform: iOS
|
||||
deploymentTarget: "17.0"
|
||||
sources:
|
||||
- path: MealMood
|
||||
resources:
|
||||
- path: MealMood/Configuration/MealMood.storekit
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.alexandrevazquez.mealmood
|
||||
MARKETING_VERSION: 1.0.0
|
||||
CURRENT_PROJECT_VERSION: 1
|
||||
PRODUCT_NAME: MealMood
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
INFOPLIST_FILE: MealMood/Resources/Info.plist
|
||||
GENERATE_INFOPLIST_FILE: false
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
DEVELOPMENT_TEAM: ""
|
||||
CODE_SIGN_ENTITLEMENTS: MealMood/Resources/MealMood.entitlements
|
||||
dependencies:
|
||||
- package: GoogleMobileAds
|
||||
product: GoogleMobileAds
|
||||
scheme:
|
||||
storeKitConfiguration: ../../../MealMood/Configuration/MealMood.storekit
|
||||
testTargets:
|
||||
- MealMoodTests
|
||||
|
||||
MealMoodTests:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
deploymentTarget: "17.0"
|
||||
sources:
|
||||
- MealMoodTests
|
||||
dependencies:
|
||||
- target: MealMood
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.alexandrevazquez.mealmood.tests
|
||||
GENERATE_INFOPLIST_FILE: true
|
||||
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/MealMood.app/MealMood"
|
||||