commit e593453abd55ed84ea5035238c5247b542ccc1b1 Author: alexandrev-tibco Date: Tue Feb 17 13:34:02 2026 +0100 Version casi lista diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..30d7846 Binary files /dev/null and b/.DS_Store differ diff --git a/MealMood.storekit b/MealMood.storekit new file mode 100644 index 0000000..f6ab2b8 --- /dev/null +++ b/MealMood.storekit @@ -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 + } +} diff --git a/MealMood.xcodeproj/project.pbxproj b/MealMood.xcodeproj/project.pbxproj new file mode 100644 index 0000000..7810967 --- /dev/null +++ b/MealMood.xcodeproj/project.pbxproj @@ -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 = ""; }; + 0164A33496A304E40685EA6D /* Localization+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Localization+Helpers.swift"; sourceTree = ""; }; + 035C6C0B728EB0EA80394CDD /* WeekendsStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekendsStepView.swift; sourceTree = ""; }; + 049AE68D5B577514ADD6BACB /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; + 050EA827CA951AB9E847E2CA /* AdBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdBannerView.swift; sourceTree = ""; }; + 065982B284538632951F1C3A /* MealMoodLogo.svg */ = {isa = PBXFileReference; lastKnownFileType = text; path = MealMoodLogo.svg; sourceTree = ""; }; + 0F46C32F97EAD52F8F3BC0B7 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 103374A7D91B4F884278DA72 /* StoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreManager.swift; sourceTree = ""; }; + 10594EA55F6BEFF4241D364C /* FirstDishesStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstDishesStepView.swift; sourceTree = ""; }; + 13DB00DA865F6FE86952BFC3 /* TagListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagListView.swift; sourceTree = ""; }; + 168A6CA95246E1BB72797D4E /* OnboardingViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewModelTests.swift; sourceTree = ""; }; + 17B976BF8B0BD6D1AA5F8C68 /* TagLocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagLocalizationTests.swift; sourceTree = ""; }; + 17E00AB23342224B3407838B /* DefaultDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultDataService.swift; sourceTree = ""; }; + 20F23BF9788A3217E3D63721 /* HapticManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticManager.swift; sourceTree = ""; }; + 23957C735075B0DFB1DF51D7 /* DishDrawerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DishDrawerView.swift; sourceTree = ""; }; + 2C20D9FC522FEBF7206CFD32 /* MealMoodApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealMoodApp.swift; sourceTree = ""; }; + 2C5CD466A50C339519544347 /* TagRulesEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagRulesEditView.swift; sourceTree = ""; }; + 2E113A29DEC0551C41AB7EBB /* WeekPlanShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekPlanShareView.swift; sourceTree = ""; }; + 324D77FEC4C8C371695F27CC /* PremiumView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumView.swift; sourceTree = ""; }; + 4EDEE086FD20D16DE507B247 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; + 50E1C12C3A693D7C41E9F80B /* TagPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagPill.swift; sourceTree = ""; }; + 5187023D83BBC5E8D304CE3A /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; + 5C9BFFE7A4BC6961815D2334 /* TagViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagViewModel.swift; sourceTree = ""; }; + 60F0A5C2FF1B797A4BB69B39 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 623EDDC4CD134883FC9D242D /* Dish.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dish.swift; sourceTree = ""; }; + 69B309A43E486B5A723324BA /* WeekCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekCalendarView.swift; sourceTree = ""; }; + 6D718B2E4F7E6D22E502F0A6 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; + 6EA1948B5DAEEEA09B78C8A9 /* SecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryButton.swift; sourceTree = ""; }; + 70D140A6E835CA6227C2D4D9 /* WelcomeStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeStepView.swift; sourceTree = ""; }; + 7603187713D390BD4D35770C /* AutocompleteEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteEngineTests.swift; sourceTree = ""; }; + 76719058DD3A4177B2B4163E /* CalendarStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarStepView.swift; sourceTree = ""; }; + 797A41D292142240FBDF9F2A /* MealSlot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealSlot.swift; sourceTree = ""; }; + 7DE70CD2A01FCE5370A46DD3 /* Date+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Helpers.swift"; sourceTree = ""; }; + 812303C94C8C1D2FADF3CB37 /* AdMobConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdMobConfig.swift; sourceTree = ""; }; + 8411FF2737FDEF48170F9634 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 89C47DD7B8F4B1441780E611 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + 8CFA001E1BFA916ABC4C44C1 /* PremiumUpsellBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumUpsellBanner.swift; sourceTree = ""; }; + 8EE10285FEE9BE0684CC66A3 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + 965555A7774FF7EB975F0A07 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + 9F86EB5DDB126D73486FD956 /* MealMood.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MealMood.entitlements; sourceTree = ""; }; + 9FBA11C8A81D5C0CA8A3C13D /* MealWindowsStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealWindowsStepView.swift; sourceTree = ""; }; + A23267BAA116E1214870416C /* WeekPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekPlan.swift; sourceTree = ""; }; + AB771E85924AE086647A5113 /* Color+MealMood.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+MealMood.swift"; sourceTree = ""; }; + B0A631C6D0CF7A0724ACF6A5 /* CalendarService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarService.swift; sourceTree = ""; }; + B3944B4AD876455057EB2265 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + B68E5C82608F60FF8798BB81 /* EmptySlotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptySlotView.swift; sourceTree = ""; }; + B6CF1C9CC9BDB3B20B1834EB /* TagSelectorSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagSelectorSheet.swift; sourceTree = ""; }; + BAC64110469AA91D5F28C423 /* MealMoodLogoMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealMoodLogoMark.swift; sourceTree = ""; }; + BED090ADA1FB259AD2AFEDE2 /* ICloudSyncService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ICloudSyncService.swift; sourceTree = ""; }; + 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 = ""; }; + CB3BD0E078644749E93B0D2B /* DishListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DishListView.swift; sourceTree = ""; }; + CE7AB2ECCA7D330E50AC549E /* AutocompleteEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteEngine.swift; sourceTree = ""; }; + CFBCD4FFE0D067A591104B74 /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; }; + D63CA2A8666E1FBBAD65F141 /* DishFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DishFormView.swift; sourceTree = ""; }; + DBD33453B7DF86EDBAEB80F1 /* DateHelpersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateHelpersTests.swift; sourceTree = ""; }; + DFA97AD958C922D8AFF73314 /* PrimaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryButton.swift; sourceTree = ""; }; + E8472961AE65E2A3460B67C3 /* DishViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DishViewModel.swift; sourceTree = ""; }; + E8825212ACFA0D3F6C716E94 /* ReviewPromptService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPromptService.swift; sourceTree = ""; }; + F0200C0C11C912711C724558 /* PremiumAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumAccess.swift; sourceTree = ""; }; + 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 = ""; }; + FD4FD72583C004A5D5C939A3 /* FilledSlotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilledSlotView.swift; sourceTree = ""; }; + FD621CE9B44512AE8894D92E /* OnboardingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewModel.swift; sourceTree = ""; }; + FE73D5B7AF8B5E2D63105977 /* Font+MealMood.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Font+MealMood.swift"; sourceTree = ""; }; + FF2DE77AD57DB6DAB188CD9E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; +/* 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 = ""; + }; + 0C0966502AF8E7F06CC92048 /* Home */ = { + isa = PBXGroup; + children = ( + 23957C735075B0DFB1DF51D7 /* DishDrawerView.swift */, + 89C47DD7B8F4B1441780E611 /* HomeView.swift */, + 69B309A43E486B5A723324BA /* WeekCalendarView.swift */, + 2E113A29DEC0551C41AB7EBB /* WeekPlanShareView.swift */, + ); + path = Home; + sourceTree = ""; + }; + 35F15C6817E504B1114420CD /* Premium */ = { + isa = PBXGroup; + children = ( + 324D77FEC4C8C371695F27CC /* PremiumView.swift */, + ); + path = Premium; + sourceTree = ""; + }; + 4B4ACE1D0F2968B284A1FA7E /* Configuration */ = { + isa = PBXGroup; + children = ( + ); + path = Configuration; + sourceTree = ""; + }; + 4B80B7CABE3B737897455A26 /* Settings */ = { + isa = PBXGroup; + children = ( + 0F46C32F97EAD52F8F3BC0B7 /* SettingsView.swift */, + ); + path = Settings; + sourceTree = ""; + }; + 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 = ""; + }; + 4ED1758EE5CD0A3EB9D9BCDF /* Brand */ = { + isa = PBXGroup; + children = ( + 065982B284538632951F1C3A /* MealMoodLogo.svg */, + ); + path = Brand; + sourceTree = ""; + }; + 53F699BBFA2A0DDDA1504E9B /* ViewModels */ = { + isa = PBXGroup; + children = ( + E8472961AE65E2A3460B67C3 /* DishViewModel.swift */, + 4EDEE086FD20D16DE507B247 /* HomeViewModel.swift */, + FD621CE9B44512AE8894D92E /* OnboardingViewModel.swift */, + 5187023D83BBC5E8D304CE3A /* SettingsViewModel.swift */, + 5C9BFFE7A4BC6961815D2334 /* TagViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + 56A59DD223BBB14856A48F78 /* Models */ = { + isa = PBXGroup; + children = ( + C7C00EED19123F00AE87FF24 /* AppSettings.swift */, + 623EDDC4CD134883FC9D242D /* Dish.swift */, + 797A41D292142240FBDF9F2A /* MealSlot.swift */, + 049AE68D5B577514ADD6BACB /* Tag.swift */, + A23267BAA116E1214870416C /* WeekPlan.swift */, + ); + path = Models; + sourceTree = ""; + }; + 5BEE00A9E25BDAA086585241 /* Products */ = { + isa = PBXGroup; + children = ( + C7B13F4D4D362E50CA65A0C1 /* MealMood.app */, + F766AA7898D6F391DE1B9A81 /* MealMoodTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 7975F99C73940CF3F40F658E /* MealMoodTests */ = { + isa = PBXGroup; + children = ( + 7603187713D390BD4D35770C /* AutocompleteEngineTests.swift */, + DBD33453B7DF86EDBAEB80F1 /* DateHelpersTests.swift */, + 168A6CA95246E1BB72797D4E /* OnboardingViewModelTests.swift */, + 17B976BF8B0BD6D1AA5F8C68 /* TagLocalizationTests.swift */, + ); + path = MealMoodTests; + sourceTree = ""; + }; + 86967708171329E855F4A8AC /* Views */ = { + isa = PBXGroup; + children = ( + 8BE925662F55707DF083677A /* Dishes */, + 0C0966502AF8E7F06CC92048 /* Home */, + E452C59D71AAD6175947503D /* Onboarding */, + 35F15C6817E504B1114420CD /* Premium */, + 4B80B7CABE3B737897455A26 /* Settings */, + CE41931687CCC60F76A2B6D9 /* Tags */, + ); + path = Views; + sourceTree = ""; + }; + 8BE925662F55707DF083677A /* Dishes */ = { + isa = PBXGroup; + children = ( + D63CA2A8666E1FBBAD65F141 /* DishFormView.swift */, + CB3BD0E078644749E93B0D2B /* DishListView.swift */, + B6CF1C9CC9BDB3B20B1834EB /* TagSelectorSheet.swift */, + ); + path = Dishes; + sourceTree = ""; + }; + 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 = ""; + }; + 9E695EAC119C9913E6A36CC8 /* Resources */ = { + isa = PBXGroup; + children = ( + B3944B4AD876455057EB2265 /* Assets.xcassets */, + 8411FF2737FDEF48170F9634 /* Info.plist */, + 8E15828872FAEA71A7384099 /* Localizable.strings */, + 9F86EB5DDB126D73486FD956 /* MealMood.entitlements */, + 4ED1758EE5CD0A3EB9D9BCDF /* Brand */, + ); + path = Resources; + sourceTree = ""; + }; + CE41931687CCC60F76A2B6D9 /* Tags */ = { + isa = PBXGroup; + children = ( + 13DB00DA865F6FE86952BFC3 /* TagListView.swift */, + 2C5CD466A50C339519544347 /* TagRulesEditView.swift */, + ); + path = Tags; + sourceTree = ""; + }; + 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 = ""; + }; + 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 = ""; + }; + 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 = ""; + }; +/* 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 = ""; + }; +/* 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 */; +} diff --git a/MealMood.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/MealMood.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/MealMood.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/MealMood.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/MealMood.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..b48b7ba --- /dev/null +++ b/MealMood.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 +} diff --git a/MealMood.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate b/MealMood.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..9387c09 Binary files /dev/null and b/MealMood.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/MealMood.xcodeproj/xcshareddata/xcschemes/MealMood-IAP.xcscheme b/MealMood.xcodeproj/xcshareddata/xcschemes/MealMood-IAP.xcscheme new file mode 100644 index 0000000..52cffa8 --- /dev/null +++ b/MealMood.xcodeproj/xcshareddata/xcschemes/MealMood-IAP.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MealMood.xcodeproj/xcshareddata/xcschemes/MealMood.xcscheme b/MealMood.xcodeproj/xcshareddata/xcschemes/MealMood.xcscheme new file mode 100644 index 0000000..452119b --- /dev/null +++ b/MealMood.xcodeproj/xcshareddata/xcschemes/MealMood.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MealMood.xcodeproj/xcuserdata/alexandrev.xcuserdatad/xcschemes/xcschememanagement.plist b/MealMood.xcodeproj/xcuserdata/alexandrev.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..8688c62 --- /dev/null +++ b/MealMood.xcodeproj/xcuserdata/alexandrev.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + MealMood-IAP.xcscheme_^#shared#^_ + + isShown + + + + + diff --git a/MealMood/.DS_Store b/MealMood/.DS_Store new file mode 100644 index 0000000..79ab901 Binary files /dev/null and b/MealMood/.DS_Store differ diff --git a/MealMood/Components/AdBannerView.swift b/MealMood/Components/AdBannerView.swift new file mode 100644 index 0000000..2bdfa02 --- /dev/null +++ b/MealMood/Components/AdBannerView.swift @@ -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) { + 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 diff --git a/MealMood/Components/AppIconPlaceholder.swift b/MealMood/Components/AppIconPlaceholder.swift new file mode 100644 index 0000000..1c2d4e0 --- /dev/null +++ b/MealMood/Components/AppIconPlaceholder.swift @@ -0,0 +1,9 @@ +import SwiftUI + +struct AppIconPlaceholder: View { + var size: CGFloat = 120 + + var body: some View { + MealMoodLogoMark(size: size) + } +} diff --git a/MealMood/Components/EmptySlotView.swift b/MealMood/Components/EmptySlotView.swift new file mode 100644 index 0000000..4471533 --- /dev/null +++ b/MealMood/Components/EmptySlotView.swift @@ -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 + } +} diff --git a/MealMood/Components/FilledSlotView.swift b/MealMood/Components/FilledSlotView.swift new file mode 100644 index 0000000..29c4b85 --- /dev/null +++ b/MealMood/Components/FilledSlotView.swift @@ -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() + } +} diff --git a/MealMood/Components/MealMoodLogoMark.swift b/MealMood/Components/MealMoodLogoMark.swift new file mode 100644 index 0000000..732d546 --- /dev/null +++ b/MealMood/Components/MealMoodLogoMark.swift @@ -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) + } +} diff --git a/MealMood/Components/PremiumUpsellBanner.swift b/MealMood/Components/PremiumUpsellBanner.swift new file mode 100644 index 0000000..92f7ed9 --- /dev/null +++ b/MealMood/Components/PremiumUpsellBanner.swift @@ -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) + } +} diff --git a/MealMood/Components/PrimaryButton.swift b/MealMood/Components/PrimaryButton.swift new file mode 100644 index 0000000..e837f07 --- /dev/null +++ b/MealMood/Components/PrimaryButton.swift @@ -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) + } +} diff --git a/MealMood/Components/SecondaryButton.swift b/MealMood/Components/SecondaryButton.swift new file mode 100644 index 0000000..ed4bd9c --- /dev/null +++ b/MealMood/Components/SecondaryButton.swift @@ -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) + } + } +} diff --git a/MealMood/Components/TagPill.swift b/MealMood/Components/TagPill.swift new file mode 100644 index 0000000..aa61c1b --- /dev/null +++ b/MealMood/Components/TagPill.swift @@ -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) + ) + } +} diff --git a/MealMood/Components/ToastView.swift b/MealMood/Components/ToastView.swift new file mode 100644 index 0000000..d714cc5 --- /dev/null +++ b/MealMood/Components/ToastView.swift @@ -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, message: String) -> some View { + modifier(ToastModifier(isShowing: isShowing, message: message)) + } +} diff --git a/MealMood/Configuration/MealMood.storekit b/MealMood/Configuration/MealMood.storekit new file mode 100644 index 0000000..db1b232 --- /dev/null +++ b/MealMood/Configuration/MealMood.storekit @@ -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 + } +} diff --git a/MealMood/Configuration/MealMoodV4.storekit b/MealMood/Configuration/MealMoodV4.storekit new file mode 100644 index 0000000..44ad077 --- /dev/null +++ b/MealMood/Configuration/MealMoodV4.storekit @@ -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 + } +} diff --git a/MealMood/ContentView.swift b/MealMood/ContentView.swift new file mode 100644 index 0000000..6988a5d --- /dev/null +++ b/MealMood/ContentView.swift @@ -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() + if (try? context.fetch(tagDescriptor))?.isEmpty ?? true { + DefaultDataService.createDefaultTags(context: context) + } + } + + private func syncPremiumStatus() async { + let descriptor = FetchDescriptor() + 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() + } + } +} diff --git a/MealMood/Extensions/Color+MealMood.swift b/MealMood/Extensions/Color+MealMood.swift new file mode 100644 index 0000000..d0b199a --- /dev/null +++ b/MealMood/Extensions/Color+MealMood.swift @@ -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) + } +} diff --git a/MealMood/Extensions/Date+Helpers.swift b/MealMood/Extensions/Date+Helpers.swift new file mode 100644 index 0000000..2ac07c2 --- /dev/null +++ b/MealMood/Extensions/Date+Helpers.swift @@ -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 +} diff --git a/MealMood/Extensions/Font+MealMood.swift b/MealMood/Extensions/Font+MealMood.swift new file mode 100644 index 0000000..a727911 --- /dev/null +++ b/MealMood/Extensions/Font+MealMood.swift @@ -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) +} diff --git a/MealMood/Extensions/Localization+Helpers.swift b/MealMood/Extensions/Localization+Helpers.swift new file mode 100644 index 0000000..7a03866 --- /dev/null +++ b/MealMood/Extensions/Localization+Helpers.swift @@ -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: "") +} diff --git a/MealMood/Extensions/View+MealCard.swift b/MealMood/Extensions/View+MealCard.swift new file mode 100644 index 0000000..278d60a --- /dev/null +++ b/MealMood/Extensions/View+MealCard.swift @@ -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()) + } +} diff --git a/MealMood/MealMoodApp.swift b/MealMood/MealMoodApp.swift new file mode 100644 index 0000000..c0a73f8 --- /dev/null +++ b/MealMood/MealMoodApp.swift @@ -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) + } +} diff --git a/MealMood/Models/AppSettings.swift b/MealMood/Models/AppSettings.swift new file mode 100644 index 0000000..aa1c58e --- /dev/null +++ b/MealMood/Models/AppSettings.swift @@ -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" + } + } +} diff --git a/MealMood/Models/Dish.swift b/MealMood/Models/Dish.swift new file mode 100644 index 0000000..7a4e800 --- /dev/null +++ b/MealMood/Models/Dish.swift @@ -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 + } +} diff --git a/MealMood/Models/MealSlot.swift b/MealMood/Models/MealSlot.swift new file mode 100644 index 0000000..d25115a --- /dev/null +++ b/MealMood/Models/MealSlot.swift @@ -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 } + } +} diff --git a/MealMood/Models/Tag.swift b/MealMood/Models/Tag.swift new file mode 100644 index 0000000..ca9d695 --- /dev/null +++ b/MealMood/Models/Tag.swift @@ -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: ", ") + } +} diff --git a/MealMood/Models/WeekPlan.swift b/MealMood/Models/WeekPlan.swift new file mode 100644 index 0000000..ebd26ef --- /dev/null +++ b/MealMood/Models/WeekPlan.swift @@ -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 + } +} diff --git a/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..eefb4b4 --- /dev/null +++ b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 + } +} diff --git a/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000..5068091 Binary files /dev/null and b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-20@1x-ipad.png b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-20@1x-ipad.png new file mode 100644 index 0000000..dd8add5 Binary files /dev/null and b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-20@1x-ipad.png differ diff --git a/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-20@2x-ipad.png b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-20@2x-ipad.png new file mode 100644 index 0000000..1e37c5f Binary files /dev/null and b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-20@2x-ipad.png differ diff --git a/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png new file mode 100644 index 0000000..1e37c5f Binary files /dev/null and b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png differ diff --git a/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png new file mode 100644 index 0000000..47d8bca Binary files /dev/null and b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png differ diff --git a/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-29@1x-ipad.png b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-29@1x-ipad.png new file mode 100644 index 0000000..9e41774 Binary files /dev/null and b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-29@1x-ipad.png differ diff --git a/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-29@2x-ipad.png b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-29@2x-ipad.png new file mode 100644 index 0000000..65568f7 Binary files /dev/null and b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-29@2x-ipad.png differ diff --git a/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png new file mode 100644 index 0000000..65568f7 Binary files /dev/null and b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png differ diff --git a/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png new file mode 100644 index 0000000..692c03c Binary files /dev/null and b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png differ diff --git a/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@1x-ipad.png b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@1x-ipad.png new file mode 100644 index 0000000..1e37c5f Binary files /dev/null and b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@1x-ipad.png differ diff --git a/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@2x-ipad.png b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@2x-ipad.png new file mode 100644 index 0000000..488ef03 Binary files /dev/null and b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@2x-ipad.png differ diff --git a/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png new file mode 100644 index 0000000..488ef03 Binary files /dev/null and b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png differ diff --git a/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png new file mode 100644 index 0000000..ab7bb56 Binary files /dev/null and b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png differ diff --git a/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png new file mode 100644 index 0000000..ab7bb56 Binary files /dev/null and b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png differ diff --git a/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png new file mode 100644 index 0000000..f1b6d57 Binary files /dev/null and b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png differ diff --git a/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-76@1x.png b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-76@1x.png new file mode 100644 index 0000000..a6e7043 Binary files /dev/null and b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-76@1x.png differ diff --git a/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png new file mode 100644 index 0000000..8cbdcab Binary files /dev/null and b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png differ diff --git a/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png new file mode 100644 index 0000000..9ebeab6 Binary files /dev/null and b/MealMood/Resources/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png differ diff --git a/MealMood/Resources/Assets.xcassets/AppLogo.imageset/AppLogo.png b/MealMood/Resources/Assets.xcassets/AppLogo.imageset/AppLogo.png new file mode 100644 index 0000000..5068091 Binary files /dev/null and b/MealMood/Resources/Assets.xcassets/AppLogo.imageset/AppLogo.png differ diff --git a/MealMood/Resources/Assets.xcassets/AppLogo.imageset/Contents.json b/MealMood/Resources/Assets.xcassets/AppLogo.imageset/Contents.json new file mode 100644 index 0000000..4651283 --- /dev/null +++ b/MealMood/Resources/Assets.xcassets/AppLogo.imageset/Contents.json @@ -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 + } +} diff --git a/MealMood/Resources/Brand/MealMoodLogo.svg b/MealMood/Resources/Brand/MealMoodLogo.svg new file mode 100644 index 0000000..aba8a6f --- /dev/null +++ b/MealMood/Resources/Brand/MealMoodLogo.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MealMood/Resources/Info.plist b/MealMood/Resources/Info.plist new file mode 100644 index 0000000..bd02d56 --- /dev/null +++ b/MealMood/Resources/Info.plist @@ -0,0 +1,57 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + GADApplicationIdentifier + ca-app-pub-1549720748100858~9985112590 + LSRequiresIPhoneOS + + NSCalendarsFullAccessUsageDescription + MealMood needs calendar access to sync your meal plan. + NSCalendarsWriteOnlyAccessUsageDescription + MealMood needs calendar access to create meal events. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UILaunchScreen + + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/MealMood/Resources/MealMood.entitlements b/MealMood/Resources/MealMood.entitlements new file mode 100644 index 0000000..cf687e0 --- /dev/null +++ b/MealMood/Resources/MealMood.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.developer.icloud-container-identifiers + + iCloud.com.alexandrev.mealmood + + com.apple.developer.ubiquity-kvstore-identifier + $(TeamIdentifierPrefix)$(CFBundleIdentifier) + + diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..8c61f72 --- /dev/null +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -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"; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings new file mode 100644 index 0000000..afa23b5 --- /dev/null +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -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"; diff --git a/MealMood/Services/AdMobConfig.swift b/MealMood/Services/AdMobConfig.swift new file mode 100644 index 0000000..5e3f6f2 --- /dev/null +++ b/MealMood/Services/AdMobConfig.swift @@ -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 + } +} diff --git a/MealMood/Services/AutocompleteEngine.swift b/MealMood/Services/AutocompleteEngine.swift new file mode 100644 index 0000000..1daffee --- /dev/null +++ b/MealMood/Services/AutocompleteEngine.swift @@ -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.. 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) + } + } +} diff --git a/MealMood/Services/DefaultDataService.swift b/MealMood/Services/DefaultDataService.swift new file mode 100644 index 0000000..fc1e783 --- /dev/null +++ b/MealMood/Services/DefaultDataService.swift @@ -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 + } +} diff --git a/MealMood/Services/HapticManager.swift b/MealMood/Services/HapticManager.swift new file mode 100644 index 0000000..318cdab --- /dev/null +++ b/MealMood/Services/HapticManager.swift @@ -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() + } +} diff --git a/MealMood/Services/ICloudSyncService.swift b/MealMood/Services/ICloudSyncService.swift new file mode 100644 index 0000000..93f7084 --- /dev/null +++ b/MealMood/Services/ICloudSyncService.swift @@ -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()))?.first + let tags = (try? context.fetch(FetchDescriptor())) ?? [] + let dishes = (try? context.fetch(FetchDescriptor())) ?? [] + let plans = (try? context.fetch(FetchDescriptor())) ?? [] + + 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())) ?? [] + let existingPlans = (try? context.fetch(FetchDescriptor())) ?? [] + let existingDishes = (try? context.fetch(FetchDescriptor())) ?? [] + let existingTags = (try? context.fetch(FetchDescriptor())) ?? [] + let existingSettings = (try? context.fetch(FetchDescriptor())) ?? [] + + 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 +} diff --git a/MealMood/Services/NotificationService.swift b/MealMood/Services/NotificationService.swift new file mode 100644 index 0000000..c252899 --- /dev/null +++ b/MealMood/Services/NotificationService.swift @@ -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) + } +} + diff --git a/MealMood/Services/PremiumAccess.swift b/MealMood/Services/PremiumAccess.swift new file mode 100644 index 0000000..8ceb537 --- /dev/null +++ b/MealMood/Services/PremiumAccess.swift @@ -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 + } +} diff --git a/MealMood/Services/ReviewPromptService.swift b/MealMood/Services/ReviewPromptService.swift new file mode 100644 index 0000000..acfd34d --- /dev/null +++ b/MealMood/Services/ReviewPromptService.swift @@ -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) + } +} diff --git a/MealMood/Services/StoreManager.swift b/MealMood/Services/StoreManager.swift new file mode 100644 index 0000000..bcb5794 --- /dev/null +++ b/MealMood/Services/StoreManager.swift @@ -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() + 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) + } +} diff --git a/MealMood/ViewModels/DishViewModel.swift b/MealMood/ViewModels/DishViewModel.swift new file mode 100644 index 0000000..5c0e7b7 --- /dev/null +++ b/MealMood/ViewModels/DishViewModel.swift @@ -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 = [] + @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( + predicate: #Predicate { plan in + plan.weekStartDate == currentWeekStart + } + ) + + guard let currentPlan = try? context.fetch(descriptor).first else { + return false + } + + return currentPlan.slots.contains { $0.dishId == dishId } + } +} diff --git a/MealMood/ViewModels/HomeViewModel.swift b/MealMood/ViewModels/HomeViewModel.swift new file mode 100644 index 0000000..c038b18 --- /dev/null +++ b/MealMood/ViewModels/HomeViewModel.swift @@ -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( + predicate: #Predicate { 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() + 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() + 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() + } + } +} diff --git a/MealMood/ViewModels/OnboardingViewModel.swift b/MealMood/ViewModels/OnboardingViewModel.swift new file mode 100644 index 0000000..fe62488 --- /dev/null +++ b/MealMood/ViewModels/OnboardingViewModel.swift @@ -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 = [] + @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() + 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() + 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 + } + } +} diff --git a/MealMood/ViewModels/SettingsViewModel.swift b/MealMood/ViewModels/SettingsViewModel.swift new file mode 100644 index 0000000..c3b7e3e --- /dev/null +++ b/MealMood/ViewModels/SettingsViewModel.swift @@ -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(of type: T.Type, in context: ModelContext) throws { + let descriptor = FetchDescriptor() + let models = try context.fetch(descriptor) + for model in models { + context.delete(model) + } + try context.save() + } +} diff --git a/MealMood/ViewModels/TagViewModel.swift b/MealMood/ViewModels/TagViewModel.swift new file mode 100644 index 0000000..2cca259 --- /dev/null +++ b/MealMood/ViewModels/TagViewModel.swift @@ -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 + } +} diff --git a/MealMood/Views/Dishes/DishFormView.swift b/MealMood/Views/Dishes/DishFormView.swift new file mode 100644 index 0000000..0e4ea86 --- /dev/null +++ b/MealMood/Views/Dishes/DishFormView.swift @@ -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) + } +} diff --git a/MealMood/Views/Dishes/DishListView.swift b/MealMood/Views/Dishes/DishListView.swift new file mode 100644 index 0000000..59dfc35 --- /dev/null +++ b/MealMood/Views/Dishes/DishListView.swift @@ -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() + } +} diff --git a/MealMood/Views/Dishes/TagSelectorSheet.swift b/MealMood/Views/Dishes/TagSelectorSheet.swift new file mode 100644 index 0000000..83151a1 --- /dev/null +++ b/MealMood/Views/Dishes/TagSelectorSheet.swift @@ -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 + @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() + if (try? context.fetch(descriptor))?.isEmpty ?? true { + DefaultDataService.createDefaultTags(context: context) + } + } +} diff --git a/MealMood/Views/Home/DishDrawerView.swift b/MealMood/Views/Home/DishDrawerView.swift new file mode 100644 index 0000000..574588f --- /dev/null +++ b/MealMood/Views/Home/DishDrawerView.swift @@ -0,0 +1,202 @@ +import SwiftUI + +struct DishDrawerView: View { + let dishes: [Dish] + let tags: [Tag] + let language: AppLanguage + let usedDishIds: Set + 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() + } +} diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift new file mode 100644 index 0000000..680c831 --- /dev/null +++ b/MealMood/Views/Home/HomeView.swift @@ -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( + predicate: #Predicate { plan in + plan.weekStartDate == weekStartDate + } + ) + return try? context.fetch(descriptor).first + } + + private func evaluateReviewPrompt() { + let descriptor = FetchDescriptor() + 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))" + } +} diff --git a/MealMood/Views/Home/WeekCalendarView.swift b/MealMood/Views/Home/WeekCalendarView.swift new file mode 100644 index 0000000..d52ed1c --- /dev/null +++ b/MealMood/Views/Home/WeekCalendarView.swift @@ -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 { + 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 "" + } + } +} diff --git a/MealMood/Views/Home/WeekPlanShareView.swift b/MealMood/Views/Home/WeekPlanShareView.swift new file mode 100644 index 0000000..abbe198 --- /dev/null +++ b/MealMood/Views/Home/WeekPlanShareView.swift @@ -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 { + 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)) + } +} diff --git a/MealMood/Views/Onboarding/CalendarStepView.swift b/MealMood/Views/Onboarding/CalendarStepView.swift new file mode 100644 index 0000000..b786c5f --- /dev/null +++ b/MealMood/Views/Onboarding/CalendarStepView.swift @@ -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") + } + } +} diff --git a/MealMood/Views/Onboarding/FirstDishesStepView.swift b/MealMood/Views/Onboarding/FirstDishesStepView.swift new file mode 100644 index 0000000..985f399 --- /dev/null +++ b/MealMood/Views/Onboarding/FirstDishesStepView.swift @@ -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() + 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() + if (try? context.fetch(descriptor))?.isEmpty ?? true { + DefaultDataService.createDefaultTags(context: context) + } + } +} diff --git a/MealMood/Views/Onboarding/MealWindowsStepView.swift b/MealMood/Views/Onboarding/MealWindowsStepView.swift new file mode 100644 index 0000000..692ddd4 --- /dev/null +++ b/MealMood/Views/Onboarding/MealWindowsStepView.swift @@ -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) + ) + } + } +} diff --git a/MealMood/Views/Onboarding/OnboardingView.swift b/MealMood/Views/Onboarding/OnboardingView.swift new file mode 100644 index 0000000..ca2d5dd --- /dev/null +++ b/MealMood/Views/Onboarding/OnboardingView.swift @@ -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..() + if (try? context.fetch(descriptor))?.isEmpty ?? true { + DefaultDataService.createDefaultTags(context: context) + } + } +} diff --git a/MealMood/Views/Onboarding/WeekendsStepView.swift b/MealMood/Views/Onboarding/WeekendsStepView.swift new file mode 100644 index 0000000..63af5da --- /dev/null +++ b/MealMood/Views/Onboarding/WeekendsStepView.swift @@ -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) + } + } +} diff --git a/MealMood/Views/Onboarding/WelcomeStepView.swift b/MealMood/Views/Onboarding/WelcomeStepView.swift new file mode 100644 index 0000000..9d287f4 --- /dev/null +++ b/MealMood/Views/Onboarding/WelcomeStepView.swift @@ -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) + } + } +} diff --git a/MealMood/Views/Premium/PremiumView.swift b/MealMood/Views/Premium/PremiumView.swift new file mode 100644 index 0000000..680b314 --- /dev/null +++ b/MealMood/Views/Premium/PremiumView.swift @@ -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) + ) + } +} diff --git a/MealMood/Views/Settings/SettingsView.swift b/MealMood/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..a3d1bd4 --- /dev/null +++ b/MealMood/Views/Settings/SettingsView.swift @@ -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") + } + } +} diff --git a/MealMood/Views/Tags/TagListView.swift b/MealMood/Views/Tags/TagListView.swift new file mode 100644 index 0000000..293f12f --- /dev/null +++ b/MealMood/Views/Tags/TagListView.swift @@ -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) + } + } +} diff --git a/MealMood/Views/Tags/TagRulesEditView.swift b/MealMood/Views/Tags/TagRulesEditView.swift new file mode 100644 index 0000000..7f73f0a --- /dev/null +++ b/MealMood/Views/Tags/TagRulesEditView.swift @@ -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) } + } + } + } + } +} diff --git a/MealMoodTests/AutocompleteEngineTests.swift b/MealMoodTests/AutocompleteEngineTests.swift new file mode 100644 index 0000000..11de064 --- /dev/null +++ b/MealMoodTests/AutocompleteEngineTests.swift @@ -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) + } +} + diff --git a/MealMoodTests/DateHelpersTests.swift b/MealMoodTests/DateHelpersTests.swift new file mode 100644 index 0000000..d9c4a15 --- /dev/null +++ b/MealMoodTests/DateHelpersTests.swift @@ -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) + } +} + diff --git a/MealMoodTests/OnboardingViewModelTests.swift b/MealMoodTests/OnboardingViewModelTests.swift new file mode 100644 index 0000000..e87aaa4 --- /dev/null +++ b/MealMoodTests/OnboardingViewModelTests.swift @@ -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]) + } +} diff --git a/MealMoodTests/TagLocalizationTests.swift b/MealMoodTests/TagLocalizationTests.swift new file mode 100644 index 0000000..3003007 --- /dev/null +++ b/MealMoodTests/TagLocalizationTests.swift @@ -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")) + } +} + diff --git a/MealMood_PRD.md b/MealMood_PRD.md new file mode 100644 index 0000000..aefb76c --- /dev/null +++ b/MealMood_PRD.md @@ -0,0 +1,2636 @@ +# MealMood - Product Requirements Document (PRD) + +## 📋 Índice + +1. [Visión General](#visión-general) +2. [Problema y Solución](#problema-y-solución) +3. [Usuario Objetivo](#usuario-objetivo) +4. [Propuesta de Valor](#propuesta-de-valor) +5. [Arquitectura de Información](#arquitectura-de-información) +6. [Modelo de Datos](#modelo-de-datos) +7. [Funcionalidades MVP](#funcionalidades-mvp) +8. [Funcionalidades Freemium (Futuro)](#funcionalidades-freemium-futuro) +9. [Identidad Visual y Branding](#identidad-visual-y-branding) +10. [Especificaciones de Pantallas](#especificaciones-de-pantallas) +11. [Interacciones y Micro-animaciones](#interacciones-y-micro-animaciones) +12. [Integración con Calendario Nativo](#integración-con-calendario-nativo) +13. [Algoritmo de Autocompletado](#algoritmo-de-autocompletado) +14. [Sincronización y Persistencia](#sincronización-y-persistencia) +15. [Monetización](#monetización) +16. [Stack Técnico](#stack-técnico) +17. [Plan de Implementación (Sprints)](#plan-de-implementación-sprints) +18. [Anexos](#anexos) + +--- + +## Visión General + +### Nombre +**MealMood** 🍽️✨ + +### Tagline +- **Español**: "Planifica tu semana, disfruta tus comidas" +- **English**: "Plan your week, enjoy your meals" + +### Descripción del Producto +MealMood es una aplicación iOS para planificar comidas y cenas semanales de manera rápida, visual y sin estrés. Orientada principalmente a familias (target: madres), permite organizar el menú semanal en 5 minutos usando drag & drop, respetando reglas nutricionales configurables y sincronizando opcionalmente con el calendario nativo de iOS. + +**NO es un tracker de calorías ni macros**. El enfoque está en la simplicidad, variedad y disfrute de las comidas familiares. + +--- + +## Problema y Solución + +### Problema +- Planificar comidas semanales es una tarea recurrente que genera **estrés y fatiga decisional**. +- Las familias necesitan **variedad** sin repetir platos constantemente. +- Restricciones dietéticas (gluten, frecuencia de proteínas) complican la planificación. +- Apps existentes son **complejas** (trackers nutricionales) o requieren mucha configuración manual. + +### Solución +MealMood ofrece: +1. **Planificación en 5 minutos**: interfaz visual con drag & drop. +2. **Reglas configurables**: limita frecuencia de ingredientes (carne, pescado, etc.) sin ser obsesivo. +3. **Autocompletado inteligente**: algoritmo rellena huecos respetando reglas. +4. **Sincronización con calendario**: eventos automáticos en el calendario nativo de iOS. +5. **Diseño acogedor**: tonos pastel, ilustraciones suaves, experiencia sin fricción. + +--- + +## Usuario Objetivo + +### Perfil Primario +- **Madres de familia** (25-45 años). +- Con hijos pequeños/adolescentes. +- Responsables de la planificación semanal de comidas. +- Buscan **eficiencia** sin perder variedad. +- No obsesionadas con calorías/macros, pero conscientes de dieta equilibrada. + +### Perfil Secundario +- Padres que comparten la responsabilidad de cocinar. +- Personas organizadas que planifican con antelación. +- Familias con restricciones dietéticas leves (gluten, vegetarianos). + +### Jobs to Be Done (JTBD) +*"Cuando llega el domingo por la tarde y necesito planificar la semana, quiero una forma rápida de decidir qué cocinar cada día sin repetir los mismos platos, para no estresarme y que mi familia tenga variedad."* + +--- + +## Propuesta de Valor + +### Diferenciadores clave +1. **Rapidez**: planificación en 5 minutos vs. 30+ en competencia. +2. **Visual**: interfaz calendario + drag & drop vs. listas aburridas. +3. **Reglas automáticas**: evita repeticiones sin pensar manualmente. +4. **No es un tracker nutricional**: enfoque en disfrute, no en restricción. +5. **Sincronización calendario**: eventos listos para compartir con la familia. + +### Competencia +| App | Enfoque | Diferencia con MealMood | +|-----|---------|------------------------| +| **Paprika** | Recetas + listas de compra | Más complejo, orientado a recetas detalladas | +| **Mealime** | Planes nutricionales | Muy centrado en macros/calorías | +| **Plan to Eat** | Recetas + calendario | Requiere importar recetas, setup largo | +| **AnyList** | Listas de compra | No tiene planificación visual con reglas | + +--- + +## Arquitectura de Información + +### Flujo principal + +``` +Onboarding (primera vez) + ↓ +Configuración inicial + ├── Ventanas de comida (cenas / comidas / ambas) + ├── ¿Incluir fines de semana? + └── Sincronización calendario (opcional) + ↓ +Home (pantalla principal) + ├── Calendario semanal (vista principal) + ├── Drawer de platos disponibles + └── Botones: Completar / Resetear + ↓ +Gestión de platos + ├── Lista de platos + ├── Crear nuevo plato + └── Editar/Eliminar platos + ↓ +Gestión de etiquetas (Tags) + ├── Lista de tags + ├── Editar reglas de tags + └── Crear tags custom (Freemium) + ↓ +Settings + ├── Ventanas de comida + ├── Fines de semana + ├── Calendario (sincronización) + ├── Idioma + └── Premium +``` + +--- + +## Modelo de Datos + +### Entidades principales + +#### 1. Settings +```swift +struct Settings { + // General + var mealWindows: MealWindows // .dinnerOnly, .lunchOnly, .both + var includeWeekends: Bool + var language: Language // .spanish, .english + + // Calendario + var calendarId: String? // ID del calendario nativo, nil si no sincroniza + var syncEnabled: Bool + var lunchTime: Date // hora del día (default: 14:00) + var dinnerTime: Date // hora del día (default: 21:00) + var eventDuration: Int // minutos (default: 60) + var eventPrefix: String // default: "🍽️" + var reminderMinutesBefore: Int? // nil = sin alerta, valores: 30, 60, 120 + + // Premium + var isPremium: Bool +} + +enum MealWindows { + case dinnerOnly + case lunchOnly + case both +} + +enum Language { + case spanish + case english +} +``` + +#### 2. Tag (Etiqueta) +```swift +struct Tag: Identifiable, Codable { + var id: UUID + var name: String // localizado según idioma + var color: String // hex (ej: "#E74C3C") + var rules: TagRules +} + +struct TagRules: Codable { + var maxPerWeek: Int? // nil = sin límite + var noConsecutive: Bool // no puede estar en días consecutivos + var noDuplicateInDay: Bool // no puede estar en comida y cena el mismo día + var mealTypeRestriction: MealType? // .lunch, .dinner, nil +} + +enum MealType: String, Codable { + case lunch + case dinner +} +``` + +#### 3. Dish (Plato) +```swift +struct Dish: Identifiable, Codable { + var id: UUID + var name: String + var description: String? // opcional + var tags: [UUID] // referencias a Tag IDs + var createdAt: Date +} +``` + +#### 4. MealSlot (Slot de comida) +```swift +struct MealSlot: Identifiable, Codable { + var id: UUID + var dayOfWeek: Int // 0=lunes, 6=domingo + var mealType: MealType // .lunch o .dinner + var dish: UUID? // nil si vacío + var calendarEventId: String? // ID del evento en EventKit, nil si no sincroniza +} +``` + +#### 5. WeekPlan (Planificación semanal) +```swift +struct WeekPlan: Identifiable, Codable { + var id: UUID + var weekStartDate: Date // lunes de esa semana + var slots: [MealSlot] + var createdAt: Date + var updatedAt: Date +} +``` + +--- + +## Funcionalidades MVP + +### F1: Configuración Inicial (Onboarding) +**Descripción**: Primera vez que el usuario abre la app. + +**Pasos**: +1. **Bienvenida**: logo + tagline + ilustración. +2. **Ventanas de comida**: selector (solo cenas / solo comidas / ambas). +3. **Fines de semana**: toggle (incluir o no). +4. **Calendario** (opcional): selector de calendario nativo + configuración de horas. +5. **Crear primeros platos**: formulario rápido para añadir 2-3 platos iniciales. + +**Criterios de Aceptación**: +- Usuario puede completar onboarding en <2 minutos. +- Settings guardados en persistencia local (SwiftData/Core Data). +- Navegación fluida sin saltos bruscos. + +--- + +### F2: Gestión de Platos +**Descripción**: Crear, editar y eliminar platos personales. + +**Funcionalidad**: +- **Lista de platos**: muestra todos los platos del usuario. +- **Crear plato**: + - Campo: Nombre (obligatorio). + - Campo: Descripción (opcional). + - Selector: Etiquetas (multi-selección de tags disponibles). +- **Editar plato**: mismo formulario, pre-rellenado. +- **Eliminar plato**: swipe-to-delete + confirmación. + +**Validaciones**: +- Nombre no puede estar vacío. +- No permitir eliminar plato si está asignado a un slot de la semana actual (mostrar alert). + +**Criterios de Aceptación**: +- Usuario puede crear plato en <30 segundos. +- Cambios se reflejan inmediatamente en la lista de platos. +- Eliminación con confirmación para evitar errores. + +--- + +### F3: Gestión de Etiquetas (Tags) +**Descripción**: Ver y editar reglas de etiquetas predefinidas. + +**Funcionalidad**: +- **Lista de tags**: muestra tags por defecto (10 en MVP). +- **Editar reglas de tag**: + - Max por semana (slider 1-7 o "sin límite"). + - Toggle: No consecutivo. + - Toggle: No duplicar en el mismo día. + - Selector: Restricción de tipo de comida (ninguna / solo comida / solo cena). +- **Ver color del tag**: visualización en lista con pill de color. + +**Tags por defecto (MVP)**: + +| Tag (ES) | Tag (EN) | Color | Reglas por defecto | +|----------|----------|-------|-------------------| +| Carne | Meat | #E74C3C | Max 3/semana, no consecutivo, no mismo día | +| Pescado | Fish | #3498DB | Max 2/semana, no consecutivo, no mismo día | +| Legumbres | Legumes | #95A5A6 | Max 2/semana, no mismo día | +| Verduras | Vegetables | #2ECC71 | Sin límite | +| Huevos | Eggs | #F1C40F | Max 2/semana, no mismo día | +| Pasta/Arroz | Pasta/Rice | #D4AC6E | Max 3/semana, no consecutivo | +| Solo Cena | Dinner Only | #9B59B6 | Restricción: solo cena | +| Solo Comida | Lunch Only | #E67E22 | Restricción: solo comida | +| Con Gluten | Contains Gluten | #8B4513 | Informativo (sin reglas) | +| Sin Gluten | Gluten Free | #1ABC9C | Informativo (sin reglas) | + +**Criterios de Aceptación**: +- Usuario puede modificar reglas en <1 minuto. +- Cambios se aplican inmediatamente al algoritmo de validación. +- No permitir crear tags custom en MVP (solo Freemium). + +--- + +### F4: Planificación Semanal (Pantalla Principal) +**Descripción**: Vista calendario con drag & drop para asignar platos a slots. + +**Layout**: +``` +┌─────────────────────────────────────────────┐ +│ MealMood Semana 3-9 Feb [⚙️] │ +│ │ +│ ← Anterior Siguiente → │ +├─────────────────────────────────────────────┤ +│ │ +│ Lun Mar Mié Jue Vie │ +│ │ +│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ +│ │🌙 │ │🌙 │ │🌙 │ │🌙 │ │🌙 │ │ +│ │Pollo│ │Sal- │ │Torti│ │ │ │ │ │ +│ │asado│ │món │ │lla │ │ │ │ │ │ +│ │🔴 │ │🔵 │ │🟡 │ │ - - │ │ - - │ │ +│ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │ +│ │ +├─────────────────────────────────────────────┤ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ Completar │ │ Resetear │ │ +│ │ ✨ │ │ 🔄 │ │ +│ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────┘ + ▲ Arrastra platos aquí ▲ +┌─────────────────────────────────────────────┐ +│ 📋 Mis platos [+] │ +├─────────────────────────────────────────────┤ +│ ┌─────────────────────────────────────┐ │ +│ │ Pollo asado 🔴 │ │ +│ │ Jugoso y al horno │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ Salmón a la plancha 🔵 │ │ +│ │ Con limón y eneldo │ │ +│ └─────────────────────────────────────┘ │ +└─────────────────────────────────────────────┘ +``` + +**Funcionalidad**: +- **Calendario semanal**: + - Muestra días según configuración (lun-dom si incluye fines de semana, lun-vie si no). + - Muestra slots según configuración (solo cena, solo comida, o ambas). + - **Slots vacíos**: placeholder visual con icono (☀️ para comida, 🌙 para cena) + texto "Arrastra un plato". + - **Slots llenos**: muestra nombre del plato + descripción corta + pills de colores (tags). + +- **Drawer de platos**: + - Lista scrolleable de todos los platos creados. + - Card por plato: nombre + descripción + pills de tags. + - Botón [+] para crear nuevo plato (abre modal/sheet). + +- **Drag & Drop**: + - Long press (200ms) en plato → se "levanta" con sombra. + - Arrastrar sobre slot: + - **Válido** (respeta reglas): borde verde, haptic feedback suave. + - **Inválido** (viola reglas): borde rojo, haptic feedback "error". + - Drop: + - **Válido**: plato se asigna al slot, desaparece temporalmente del drawer, animación "plop". + - **Inválido**: plato vuelve al drawer con bounce animation. + +- **Navegación semanal**: + - Flechas ← → para cambiar de semana. + - Muestra rango de fechas (ej: "Semana 3-9 Feb"). + +- **Botones de acción**: + - **Completar ✨**: ejecuta algoritmo de autocompletado (ver F5). + - **Resetear 🔄**: limpia todos los slots de la semana (con confirmación). + +**Criterios de Aceptación**: +- Usuario puede planificar semana completa en <5 minutos. +- Drag & drop fluido (60fps), sin lag. +- Validación de reglas en tiempo real (antes de drop). +- Navegación entre semanas sin perder datos. + +--- + +### F5: Autocompletado Inteligente +**Descripción**: Algoritmo que rellena slots vacíos respetando reglas de tags. + +**Funcionamiento**: +1. Usuario presiona botón "Completar ✨". +2. Algoritmo analiza: + - Slots vacíos en la semana actual. + - Platos disponibles (todos los creados por el usuario). + - Reglas de cada tag asociado a cada plato. +3. Para cada slot vacío: + - Filtra platos válidos (que no violen reglas). + - Prioriza platos menos usados en la semana actual (para variedad). + - Asigna plato aleatoriamente entre válidos. +4. Si no encuentra plato válido para un slot → lo deja vacío. +5. Muestra resultado con animación secuencial (slots se rellenan uno por uno). + +**Lógica de validación**: +```python +def violates_rules(dish, slot, current_plan, rules): + for tag_id in dish.tags: + tag_rule = rules[tag_id] + + # Check max per week + if tag_rule.maxPerWeek: + count = current_plan.count_tag_usage(tag_id) + if count >= tag_rule.maxPerWeek: + return True + + # Check no consecutive + if tag_rule.noConsecutive: + adjacent_slots = current_plan.get_adjacent_slots(slot) + for adj_slot in adjacent_slots: + if adj_slot.dish and tag_id in adj_slot.dish.tags: + return True + + # Check no duplicate in same day + if tag_rule.noDuplicateInDay: + same_day_slots = current_plan.get_same_day_slots(slot) + for sd_slot in same_day_slots: + if sd_slot.dish and tag_id in sd_slot.dish.tags: + return True + + # Check meal type restriction + if tag_rule.mealTypeRestriction: + if slot.mealType != tag_rule.mealTypeRestriction: + return True + + return False +``` + +**Criterios de Aceptación**: +- Algoritmo completa semana en <2 segundos. +- Respeta todas las reglas configuradas. +- Si no puede completar todos los slots, informa al usuario ("No puedo completar con las reglas actuales. ¿Deseas relajar restricciones?"). +- Usuario puede manualmente sobrescribir asignaciones del algoritmo. + +--- + +### F6: Sincronización con Calendario Nativo +**Descripción**: Crear eventos en el calendario nativo de iOS para cada comida planificada. + +**Configuración** (Settings → Calendario): +- Toggle: Sincronizar (ON/OFF). +- Selector: Calendario nativo (lista de calendarios del dispositivo). +- Time picker: Hora de comidas (default: 14:00). +- Time picker: Hora de cenas (default: 21:00). +- Slider: Duración del evento (30min, 1h, 1.5h, 2h) - default: 1h. +- Text field: Prefijo del título (default: "🍽️"). +- Selector: Recordatorio (sin recordatorio / 30min antes / 1h antes / 2h antes). + +**Funcionamiento**: +1. **Al asignar plato a slot**: + - Si `syncEnabled == true` y `calendarId != nil`: + - Crear evento en EventKit: + - **Título**: `{eventPrefix} {mealType}: {dish.name}` (ej: "🍽️ Cena: Pollo asado"). + - **Fecha**: `weekStartDate + dayOfWeek`. + - **Hora inicio**: `lunchTime` (si es comida) o `dinnerTime` (si es cena). + - **Hora fin**: `startDate + eventDuration`. + - **Notas**: `dish.description` (si existe). + - **Alerta**: según `reminderMinutesBefore`. + - Guardar `eventId` en `slot.calendarEventId`. + +2. **Al eliminar plato de slot**: + - Si `slot.calendarEventId != nil`: + - Eliminar evento de EventKit usando `eventId`. + - Limpiar `slot.calendarEventId`. + - Mostrar toast: "Evento eliminado del calendario". + +3. **Al cambiar hora en Settings**: + - Preguntar: "¿Actualizar eventos existentes?". + - Si Sí: + - Para cada `slot` con `calendarEventId != nil` y fecha >= hoy: + - Actualizar evento en EventKit con nueva hora. + +**Permisos**: +- Solicitar permiso de EventKit al activar sincronización. +- Si usuario deniega permiso → mostrar alert explicando que necesita acceso para sincronizar (con link a Settings del sistema). + +**Casos edge**: +- Si calendario sincronizado es eliminado del dispositivo → detectar y mostrar warning: "El calendario sincronizado ya no existe. Selecciona otro." +- Si el usuario edita el evento directamente en la app Calendario nativa → **ignorar cambios** (no sincronización bidireccional en MVP). + +**Criterios de Aceptación**: +- Eventos creados correctamente con todos los campos (título, fecha, hora, notas, alerta). +- Eliminación de eventos al quitar plato del slot. +- Usuario puede desactivar sincronización en cualquier momento sin perder datos en la app. + +--- + +### F7: Settings (Ajustes) +**Descripción**: Pantalla de configuración general. + +**Secciones**: + +#### 🍽️ Planificación +- **Ventanas de comida**: selector (Solo cenas / Solo comidas / Comidas y cenas). +- **Incluir fines de semana**: toggle (ON/OFF). + +#### 📅 Calendario +- **Sincronizar**: toggle (ON/OFF). +- **Calendario**: selector de calendarios nativos (solo visible si toggle ON). +- **Hora de comidas**: time picker (default: 14:00). +- **Hora de cenas**: time picker (default: 21:00). +- **Duración del evento**: selector (30min, 1h, 1.5h, 2h). +- **Prefijo del título**: text field (default: "🍽️"). +- **Recordatorio**: selector (Sin recordatorio / 30min antes / 1h antes / 2h antes). + +#### 🏷️ Etiquetas +- **Gestionar etiquetas**: navegar a pantalla de gestión de tags. + +#### 🌍 Idioma +- **Idioma**: selector (Español / English). + +#### ⭐ Premium +- **Quitar publicidad**: navegar a pantalla de compra (IAP). +- **Estado**: mostrar si el usuario es Premium o Free. + +**Criterios de Aceptación**: +- Cambios se guardan inmediatamente (auto-save). +- Cambio de idioma actualiza toda la interfaz (incluyendo tags predefinidos). +- Navegación fluida entre Settings y otras pantallas. + +--- + +### F8: Persistencia Local +**Descripción**: Guardar datos del usuario en el dispositivo. + +**Tecnología**: SwiftData (iOS 17+) o Core Data (si necesitas compatibilidad con iOS 16). + +**Entidades persistentes**: +- Settings +- Tag (10 tags por defecto) +- Dish (platos creados por el usuario) +- WeekPlan (planificaciones semanales, al menos la actual y las próximas 2-3) + +**Comportamiento**: +- Datos se guardan automáticamente en cada cambio (no requiere botón "Guardar"). +- Al abrir la app, carga la planificación de la semana actual. +- Si no existe planificación para la semana actual, crea una nueva vacía. + +**Criterios de Aceptación**: +- Datos persisten entre sesiones de la app. +- No hay pérdida de datos al cerrar/abrir la app. +- Performance: carga inicial <1 segundo. + +--- + +### F9: Multiidioma (Español/Inglés) +**Descripción**: Soporte para español e inglés desde día 1. + +**Implementación**: +- Usar `Localizable.strings` para textos de UI. +- Tags predefinidos tienen nombre localizado (ej: "Carne" en ES, "Meat" en EN). +- Cambio de idioma en Settings → actualiza toda la interfaz sin reiniciar la app. + +**Textos a traducir**: +- Onboarding (bienvenida, pasos). +- Pantalla principal (botones, placeholders). +- Gestión de platos (formularios, validaciones). +- Gestión de tags (nombres, reglas). +- Settings (todas las opciones). +- Alerts y toasts. + +**Criterios de Aceptación**: +- Usuario puede cambiar idioma en Settings. +- Cambio se refleja inmediatamente en toda la app. +- Tags predefinidos cambian de nombre según idioma. + +--- + +### F10: Navegación entre Semanas +**Descripción**: Usuario puede planificar múltiples semanas (pasadas y futuras). + +**Funcionalidad**: +- Flechas ← → en la pantalla principal. +- Mostrar rango de fechas de la semana actual (ej: "Semana 3-9 Feb 2025"). +- Al cambiar de semana: + - Cargar planificación de esa semana (si existe). + - Si no existe, crear planificación vacía. +- **Limitación MVP**: solo se pueden editar semana actual y futuras (no pasadas). + +**Criterios de Aceptación**: +- Navegación fluida sin lag. +- Cada semana tiene su propia planificación independiente. +- Usuario puede ver hasta 4 semanas futuras. + +--- + +## Funcionalidades Freemium (Futuro) + +### Freemium 1: Tags Custom Ilimitados +- Usuario puede crear tags personalizados con nombre, color y reglas. +- En Free: solo 10 tags predefinidos, no se pueden crear nuevos. + +### Freemium 2: Platos Ilimitados +- Free: máximo 20 platos. +- Premium: ilimitados. + +### Freemium 3: Reglas Avanzadas +- **Separación mínima**: "Este plato no puede repetirse hasta X días después". +- **Día fijo**: "Este plato siempre los viernes" (tradiciones familiares). +- En Free: solo reglas básicas (max/semana, no consecutivo, no mismo día). + +### Freemium 4: Múltiples Planificaciones Guardadas +- Guardar "plantillas" de semanas (ej: "Semana típica", "Semana de invitados"). +- Aplicar plantilla a cualquier semana futura. + +### Freemium 5: Exportar a Lista de Compra +- Generar lista de ingredientes basada en la planificación semanal. +- Exportar a Notes, Reminders o apps de terceros (AnyList). + +### Freemium 6: Analytics/Histórico +- Dashboard con estadísticas: + - "¿Qué platos repito más?" + - "¿Cuántas veces comí carne esta semana?" + - Gráficos de variedad semanal. + +### Freemium 7: Colaboración Familiar +- Múltiples usuarios pueden editar la misma planificación. +- Notificaciones push: "Papá cambió la cena del jueves". + +--- + +## Identidad Visual y Branding + +### Logo +**Descripción conceptual**: +- **Forma**: circular o redondeada (amigable). +- **Icono**: plato con tenedor/cuchara cruzados, estilo line art minimalista. +- **Colores**: gradiente suave coral → menta (#FFB4A2 → #B8E6D5). +- **Líneas**: grosor 2-3px, esquinas redondeadas. +- **Opcional**: pequeño "sparkle" (✨) en la esquina superior derecha. + +**Placeholder en código**: +```swift +struct AppIconPlaceholder: View { + var body: some View { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color(hex: "#FFB4A2"), Color(hex: "#B8E6D5")], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + + Image(systemName: "fork.knife.circle") + .font(.system(size: 60, weight: .light)) + .foregroundColor(.white) + } + .frame(width: 120, height: 120) + .shadow(color: .black.opacity(0.1), radius: 10, y: 5) + } +} +``` + +--- + +### Paleta de Colores + +```swift +// Colores principales +extension Color { + // Primary + static let mealMoodCoral = Color(hex: "#FFB4A2") // Coral pastel (acento cálido) + static let mealMoodMint = Color(hex: "#B8E6D5") // Menta claro (secundario) + + // Background + static let mealMoodBackground = Color(hex: "#FFF9F5") // Crema muy claro + static let mealMoodSurface = Color.white // Blanco puro (cards) + + // Text + static let mealMoodTextPrimary = Color(hex: "#2D2D2D") // Gris oscuro + static let mealMoodTextSecondary = Color(hex: "#6B6B6B") // Gris medio + + // Feedback + static let mealMoodSuccess = Color(hex: "#A8D5BA") // Verde sage + static let mealMoodWarning = Color(hex: "#FFD6A5") // Melocotón + static let mealMoodError = Color(hex: "#FF9999") // Rojo suave + + // Helpers + 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) + } +} +``` + +--- + +### Tipografía + +```swift +// Font system +extension Font { + // Headers + 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) + + // Body + static let mealMoodBody = Font.system(size: 16, weight: .regular, design: .default) + static let mealMoodBodyBold = Font.system(size: 16, weight: .semibold, design: .default) + + // Small + static let mealMoodCaption = Font.system(size: 12, weight: .medium, design: .default) + static let mealMoodSmall = Font.system(size: 14, weight: .regular, design: .default) +} +``` + +--- + +### Componentes UI Reutilizables + +#### 1. Card Style (para slots y platos) + +```swift +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()) + } +} +``` + +#### 2. Tag Pill (visual de etiquetas) + +```swift +struct TagPill: View { + let tag: Tag + + var body: some View { + Text(tag.name) + .font(.mealMoodCaption) + .foregroundColor(.white) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + Capsule() + .fill(Color(hex: tag.color)) + ) + } +} +``` + +#### 3. Empty Slot (placeholder visual) + +```swift +struct EmptySlot: View { + let mealType: MealType + + var body: some View { + VStack(spacing: 8) { + Image(systemName: mealType == .lunch ? "sun.max.fill" : "moon.stars.fill") + .font(.system(size: 24)) + .foregroundColor(Color(hex: "#C4C4C4")) + + Text(mealType == .lunch ? "Comida" : "Cena") + .font(.mealMoodSmall) + .foregroundColor(.mealMoodTextSecondary) + + Text("Arrastra un plato") + .font(.mealMoodCaption) + .foregroundColor(Color(hex: "#A0A0A0")) + } + .frame(maxWidth: .infinity, minHeight: 100) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(hex: "#F9F9F9")) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder( + style: StrokeStyle(lineWidth: 2, dash: [5]) + ) + .foregroundColor(Color(hex: "#E0E0E0")) + ) + ) + } +} +``` + +#### 4. Filled Slot (con plato asignado) + +```swift +struct FilledSlot: View { + let dish: Dish + let tags: [Tag] + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(dish.name) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.mealMoodTextPrimary) + .lineLimit(2) + + if let description = dish.description { + Text(description) + .font(.mealMoodCaption) + .foregroundColor(.mealMoodTextSecondary) + .lineLimit(1) + } + + HStack(spacing: 4) { + ForEach(tags.prefix(3)) { tag in + TagPill(tag: tag) + } + if tags.count > 3 { + Text("+\(tags.count - 3)") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.mealMoodTextSecondary) + } + } + } + .padding(12) + .frame(maxWidth: .infinity, minHeight: 100, alignment: .topLeading) + .mealCardStyle() + } +} +``` + +#### 5. Primary Button + +```swift +struct PrimaryButton: View { + let title: String + let icon: String? + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Text(title) + .font(.mealMoodBodyBold) + if let icon = icon { + Text(icon) + } + } + .foregroundColor(.white) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background( + LinearGradient( + colors: [Color.mealMoodCoral, Color.mealMoodMint], + startPoint: .leading, + endPoint: .trailing + ) + ) + .cornerRadius(12) + .shadow(color: Color.mealMoodCoral.opacity(0.3), radius: 8, y: 4) + } + } +} +``` + +#### 6. Secondary Button + +```swift +struct SecondaryButton: View { + let title: String + let icon: String? + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Text(title) + .font(.mealMoodBodyBold) + if let icon = icon { + Text(icon) + } + } + .foregroundColor(.mealMoodTextPrimary) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background(Color.mealMoodSurface) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.mealMoodTextSecondary.opacity(0.3), lineWidth: 1.5) + ) + .cornerRadius(12) + } + } +} +``` + +--- + +## Especificaciones de Pantallas + +### Pantalla 1: Onboarding + +#### 1.1: Bienvenida + +``` +┌─────────────────────────────────────────────┐ +│ │ +│ [Logo MealMood placeholder] │ +│ │ +│ ✨ Planifica tu semana en 5 minutos │ +│ │ +│ [Ilustración: familia cenando feliz] │ +│ │ +│ • Sin estrés │ +│ • Sin contar calorías │ +│ • Sincroniza con tu calendario │ +│ │ +│ │ +│ [Comenzar] ──────────► │ +│ │ +└─────────────────────────────────────────────┘ +``` + +**Elementos**: +- Logo placeholder (usar componente AppIconPlaceholder). +- Título: H1, centrado, coral. +- Ilustración: placeholder con SF Symbols (ej: "figure.2.and.child.holdinghands"). +- Lista de beneficios: bullets con iconos. +- Botón primario: "Comenzar". + +**Navegación**: tap en "Comenzar" → Onboarding paso 1. + +--- + +#### 1.2: Paso 1 - Ventanas de Comida + +``` +┌─────────────────────────────────────────────┐ +│ ¿Qué quieres planificar? │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 🌙 Solo cenas │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ ☀️ Solo comidas │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 🌞🌙 Comidas y cenas │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ [Continuar] ────────► │ +└─────────────────────────────────────────────┘ +``` + +**Elementos**: +- Título: H2, alineado a la izquierda. +- 3 cards seleccionables (radio button style). +- Botón primario: "Continuar" (solo habilitado si hay selección). + +**Navegación**: tap en "Continuar" → Onboarding paso 2. + +--- + +#### 1.3: Paso 2 - Fines de Semana + +``` +┌─────────────────────────────────────────────┐ +│ ¿Incluir fines de semana? │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ Planificar también sábados │ │ +│ │ y domingos [ON] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ (Puedes cambiar esto después en ajustes) │ +│ │ +│ [Continuar] ────────► │ +└─────────────────────────────────────────────┘ +``` + +**Elementos**: +- Título: H2. +- Toggle prominente. +- Nota aclaratoria (small text, gris). +- Botón primario: "Continuar". + +**Navegación**: tap en "Continuar" → Onboarding paso 3. + +--- + +#### 1.4: Paso 3 - Calendario (Opcional) + +``` +┌─────────────────────────────────────────────┐ +│ ¿Sincronizar con tu calendario? │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ Sincronizar [OFF] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ [Si activas sincronización:] │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ Calendario │ │ +│ │ Personal ▼ │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ Hora de comidas 14:00 ⏰ │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ Hora de cenas 21:00 ⏰ │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ (Opcional, puedes activarlo después) │ +│ │ +│ [Omitir] [Continuar] ──────► │ +└─────────────────────────────────────────────┘ +``` + +**Elementos**: +- Toggle: "Sincronizar". +- Si toggle ON → mostrar selector de calendario + time pickers. +- Botones: "Omitir" (secondary) y "Continuar" (primary). + +**Navegación**: +- "Omitir" o "Continuar" → Onboarding paso 4. +- Si toggle ON, solicitar permiso de EventKit antes de continuar. + +--- + +#### 1.5: Paso 4 - Crear Primeros Platos + +``` +┌─────────────────────────────────────────────┐ +│ Añade tus primeros platos │ +│ │ +│ Añade al menos 2-3 platos para empezar │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ Nombre del plato │ │ +│ │ [Pollo asado ] │ │ +│ │ │ │ +│ │ Etiquetas │ │ +│ │ 🔴 Carne [×] │ │ +│ │ │ │ +│ │ [+ Añadir plato] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ Platos añadidos: │ +│ ✓ Pollo asado (🔴 Carne) │ +│ │ +│ [Finalizar] ────────► │ +└─────────────────────────────────────────────┘ +``` + +**Elementos**: +- Formulario simple: nombre + selector de tags. +- Botón "Añadir plato" (añade a lista temporal). +- Lista de platos añadidos (con opción de eliminar). +- Botón "Finalizar" (habilitado cuando hay >= 2 platos). + +**Navegación**: tap en "Finalizar" → Home (pantalla principal). + +--- + +### Pantalla 2: Home (Pantalla Principal) + +``` +┌─────────────────────────────────────────────┐ +│ MealMood Semana 3-9 Feb [⚙️] │ +│ │ +│ ← Anterior Siguiente → │ +├─────────────────────────────────────────────┤ +│ │ +│ Lun Mar Mié Jue Vie │ +│ │ +│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ +│ │🌙 │ │🌙 │ │🌙 │ │🌙 │ │🌙 │ │ +│ │Pollo│ │Sal- │ │Torti│ │ │ │ │ │ +│ │asado│ │món │ │lla │ │ │ │ │ │ +│ │🔴 │ │🔵 │ │🟡 │ │ - - │ │ - - │ │ +│ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │ +│ │ +│ [Si incluye comida y cena:] │ +│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ +│ │☀️ │ │☀️ │ │☀️ │ │☀️ │ │☀️ │ │ +│ │Lente│ │ │ │Pasta│ │ │ │ │ │ +│ │jas │ │ │ │🟡 │ │ │ │ │ │ +│ │🟢🟤 │ │ - - │ │ │ │ - - │ │ - - │ │ +│ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │ +│ │ +├─────────────────────────────────────────────┤ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ Completar │ │ Resetear │ │ +│ │ ✨ │ │ 🔄 │ │ +│ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────┘ + ▲ Arrastra platos aquí ▲ +┌─────────────────────────────────────────────┐ +│ 📋 Mis platos [+] │ +├─────────────────────────────────────────────┤ +│ ┌─────────────────────────────────────┐ │ +│ │ Pollo asado 🔴 │ │ +│ │ Jugoso y al horno │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ Salmón a la plancha 🔵 │ │ +│ │ Con limón y eneldo │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ Lentejas 🟢🟤 │ │ +│ │ Estofadas con verduras │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ... (scroll si hay más platos) │ +│ │ +│ [Banner AdMob - 50px altura] │ +└─────────────────────────────────────────────┘ +``` + +**Elementos**: + +#### Header +- Título: "MealMood" (H2, bold). +- Rango de semana: "Semana 3-9 Feb" (body, gris). +- Botón Settings: icono ⚙️ (top-right). + +#### Navegación Semanal +- Flechas: ← Anterior / Siguiente →. +- Centrado en el rango de fechas. + +#### Calendario Semanal +- **Grid de slots**: + - Orientación: horizontal scroll si incluye fines de semana (7 columnas). + - Orientación: vertical scroll si incluye comida+cena (2 filas por día). +- **Slot vacío**: usar componente `EmptySlot`. +- **Slot lleno**: usar componente `FilledSlot`. + +#### Botones de Acción +- **Completar ✨**: PrimaryButton. +- **Resetear 🔄**: SecondaryButton. + +#### Drawer de Platos +- Lista scrolleable vertical. +- Card por plato (usar `mealCardStyle`). +- Botón [+]: circular, floating, color coral. + +#### AdMob Banner (si Free) +- Altura: 50px. +- Background: mismo color que la app (#FFF9F5). +- Sticky al fondo del drawer. + +**Interacciones**: +- **Long press en plato** → drag & drop. +- **Tap en slot lleno** → opción de eliminar plato. +- **Tap en [+]** → abrir sheet "Crear Plato". +- **Tap en ⚙️** → navegar a Settings. +- **Tap en "Completar"** → ejecutar algoritmo de autocompletado. +- **Tap en "Resetear"** → mostrar alert de confirmación. + +--- + +### Pantalla 3: Crear/Editar Plato + +``` +┌─────────────────────────────────────────────┐ +│ ← Nuevo plato [✓] │ +├─────────────────────────────────────────────┤ +│ │ +│ Nombre del plato * │ +│ ┌─────────────────────────────────────┐ │ +│ │ Pollo asado │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ Descripción (opcional) │ +│ ┌─────────────────────────────────────┐ │ +│ │ Jugoso y al horno con hierbas │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ Etiquetas * │ +│ ┌──────────────────────────────────┐ │ +│ │ 🔴 Carne [×] │ │ +│ │ 🟢 Verduras [×] │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ [+ Añadir etiqueta] │ +│ │ +│ │ +│ │ +│ [Eliminar plato] (solo en modo edición) │ +└─────────────────────────────────────────────┘ +``` + +**Elementos**: +- **Header**: + - Botón back: ← (top-left). + - Título: "Nuevo plato" (crear) o "Editar plato" (editar). + - Botón guardar: ✓ (top-right, habilitado solo si campos válidos). + +- **Formulario**: + - Campo: Nombre (obligatorio, TextField). + - Campo: Descripción (opcional, TextEditor, max 100 caracteres). + - Lista de tags seleccionados (pills con botón [×] para eliminar). + - Botón "Añadir etiqueta" → abre sheet selector de tags. + +- **Botón Eliminar** (solo en modo edición): + - SecondaryButton, color rojo. + - Mostrar alert de confirmación. + +**Validaciones**: +- Nombre no puede estar vacío. +- Debe tener al menos 1 etiqueta. + +**Navegación**: +- Tap en [✓] → guardar y cerrar sheet. +- Tap en ← → descartar cambios (mostrar alert si hay cambios sin guardar). + +--- + +#### 3.1: Sheet Selector de Tags + +``` +┌─────────────────────────────────────────────┐ +│ Selecciona etiquetas [×] │ +├─────────────────────────────────────────────┤ +│ │ +│ 🔴 Carne [✓] │ +│ 🔵 Pescado [ ] │ +│ 🟢 Verduras [✓] │ +│ 🟡 Huevos [ ] │ +│ 🟤 Legumbres [ ] │ +│ 🟠 Pasta/Arroz [ ] │ +│ 🟣 Solo Cena [ ] │ +│ 🟠 Solo Comida [ ] │ +│ 🟤 Con Gluten [ ] │ +│ 🟢 Sin Gluten [ ] │ +│ │ +│ [Listo] │ +└─────────────────────────────────────────────┘ +``` + +**Elementos**: +- Lista de todos los tags disponibles. +- Checkbox por tag (multi-selección). +- Color pill al lado del nombre. +- Botón "Listo" (primary). + +**Navegación**: tap en "Listo" → cerrar sheet, actualizar lista de tags seleccionados. + +--- + +### Pantalla 4: Gestión de Platos + +``` +┌─────────────────────────────────────────────┐ +│ ← Mis platos [+] │ +├─────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ Pollo asado 🔴 [›] │ │ +│ │ Jugoso y al horno │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ Salmón a la plancha 🔵 [›] │ │ +│ │ Con limón y eneldo │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ Lentejas 🟢🟤 [›] │ │ +│ │ Estofadas con verduras │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ... (scroll si hay más platos) │ +│ │ +└─────────────────────────────────────────────┘ +``` + +**Elementos**: +- **Header**: + - Botón back: ← (top-left). + - Título: "Mis platos". + - Botón [+]: crear nuevo plato (top-right). + +- **Lista de platos**: + - Card por plato (mealCardStyle). + - Tap en card → abrir sheet "Editar Plato". + - Swipe left → botón "Eliminar" (rojo). + +**Interacciones**: +- **Tap en plato** → abrir sheet edición. +- **Swipe left** → mostrar botón "Eliminar". +- **Tap en "Eliminar"** → mostrar alert de confirmación. +- **Tap en [+]** → abrir sheet "Crear Plato". + +--- + +### Pantalla 5: Gestión de Etiquetas + +``` +┌─────────────────────────────────────────────┐ +│ ← Etiquetas │ +├─────────────────────────────────────────────┤ +│ │ +│ 🔴 Carne [›] │ +│ Max 3/semana, no consecutivo │ +│ │ +│ 🔵 Pescado [›] │ +│ Max 2/semana, no consecutivo │ +│ │ +│ 🟢 Verduras [›] │ +│ Sin límite │ +│ │ +│ 🟡 Huevos [›] │ +│ Max 2/semana, no mismo día │ +│ │ +│ ... (resto de tags) │ +│ │ +│ [+ Crear etiqueta] (solo Premium) │ +└─────────────────────────────────────────────┘ +``` + +**Elementos**: +- Lista de tags con resumen de reglas. +- Tap en tag → abrir pantalla "Editar Reglas de Tag". +- Botón [+ Crear etiqueta] (disabled en Free, enabled en Premium). + +**Navegación**: tap en tag → Pantalla 5.1. + +--- + +#### 5.1: Editar Reglas de Tag + +``` +┌─────────────────────────────────────────────┐ +│ ← Carne [✓] │ +├─────────────────────────────────────────────┤ +│ │ +│ 🔴 Carne │ +│ │ +│ Máximo por semana │ +│ ┌──────────────────────────────────┐ │ +│ │ [1] [2] [3] [4] [5] [6] [7] [∞] │ │ +│ └──────────────────────────────────┘ │ +│ Seleccionado: 3 │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ No consecutivo [ON] │ │ +│ └──────────────────────────────────┘ │ +│ Evita que aparezca días seguidos │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ No repetir en el mismo día [ON] │ │ +│ └──────────────────────────────────┘ │ +│ No puede estar en comida y cena el mismo │ +│ día │ +│ │ +│ Restricción de horario │ +│ ┌──────────────────────────────────┐ │ +│ │ Sin restricción ▼ │ │ +│ └──────────────────────────────────┘ │ +│ Opciones: Sin restricción / Solo comida / │ +│ Solo cena │ +│ │ +└─────────────────────────────────────────────┘ +``` + +**Elementos**: +- Pill de color del tag (centrado). +- **Max por semana**: segmented control (1-7, ∞). +- **Toggles**: no consecutivo, no mismo día. +- **Restricción horario**: picker (sin restricción / solo comida / solo cena). +- Botón [✓] (top-right): guardar y volver. + +**Navegación**: tap en [✓] → guardar y volver a lista de tags. + +--- + +### Pantalla 6: Settings + +``` +┌─────────────────────────────────────────────┐ +│ ← Ajustes │ +├─────────────────────────────────────────────┤ +│ │ +│ 🍽️ PLANIFICACIÓN │ +│ │ +│ Ventanas de comida │ +│ ┌──────────────────────────────────┐ │ +│ │ Solo cenas ▼ │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ Incluir fines de semana │ +│ ┌──────────────────────────────────┐ │ +│ │ [ON] │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ ───────────────────────────────────── │ +│ │ +│ 📅 CALENDARIO │ +│ │ +│ Sincronizar │ +│ ┌──────────────────────────────────┐ │ +│ │ [OFF] │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ [Si ON, mostrar:] │ +│ Calendario │ +│ ┌──────────────────────────────────┐ │ +│ │ Personal ▼ │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ Hora de comidas │ +│ ┌──────────────────────────────────┐ │ +│ │ 14:00 ⏰ │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ Hora de cenas │ +│ ┌──────────────────────────────────┐ │ +│ │ 21:00 ⏰ │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ Duración del evento │ +│ ┌──────────────────────────────────┐ │ +│ │ 1 hora ▼ │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ Prefijo del título │ +│ ┌──────────────────────────────────┐ │ +│ │ 🍽️ │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ Recordatorio │ +│ ┌──────────────────────────────────┐ │ +│ │ Sin recordatorio ▼ │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ ───────────────────────────────────── │ +│ │ +│ 🏷️ ETIQUETAS │ +│ │ +│ Gestionar etiquetas [›] │ +│ │ +│ ───────────────────────────────────── │ +│ │ +│ 🌍 IDIOMA │ +│ │ +│ Idioma │ +│ ┌──────────────────────────────────┐ │ +│ │ Español ▼ │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ ───────────────────────────────────── │ +│ │ +│ ⭐ PREMIUM │ +│ │ +│ Estado: Free │ +│ Quitar publicidad [›] │ +│ │ +└─────────────────────────────────────────────┘ +``` + +**Elementos**: +- Secciones separadas por headers (emoji + título). +- Separadores visuales (línea gris claro). +- Controles nativos de iOS (Toggle, Picker, TextField). +- Navegación a sub-pantallas con [›]. + +**Navegación**: +- **Gestionar etiquetas** → Pantalla 5. +- **Quitar publicidad** → Pantalla de IAP (In-App Purchase). + +--- + +### Pantalla 7: Premium / IAP + +``` +┌─────────────────────────────────────────────┐ +│ ← Premium [×] │ +├─────────────────────────────────────────────┤ +│ │ +│ ✨ MealMood Premium ✨ │ +│ │ +│ Disfruta de todas las funciones │ +│ │ +│ ✓ Sin publicidad │ +│ ✓ Platos ilimitados │ +│ ✓ Etiquetas personalizadas │ +│ ✓ Reglas avanzadas (próximamente) │ +│ │ +│ ───────────────────────────────────── │ +│ │ +│ Planes disponibles: │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ Mensual │ │ +│ │ $2.99 / mes │ │ +│ │ [Suscribirse] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ Anual │ │ +│ │ $19.99 / año (ahorra 44%) │ │ +│ │ [Suscribirse] │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ [Restaurar compras] │ +│ │ +└─────────────────────────────────────────────┘ +``` + +**Elementos**: +- Header con título y botón cerrar [×]. +- Logo premium (placeholder con ✨). +- Lista de beneficios con checkmarks. +- Cards de planes (mensual / anual). +- Botones "Suscribirse" (StoreKit). +- Link "Restaurar compras". + +**Navegación**: +- Tap en "Suscribirse" → StoreKit purchase flow. +- Tap en [×] → cerrar sheet. + +--- + +## Interacciones y Micro-animaciones + +### 1. Drag & Drop + +**Gestos**: +1. **Long press** (200ms) en card de plato del drawer: + - Card se "levanta" con escala 1.05. + - Sombra aumenta (radius: 12, y: 8). + - Haptic feedback: `.light`. + +2. **Drag** sobre slot: + - **Válido** (respeta reglas): + - Slot muestra borde verde (#A8D5BA, 2px). + - Haptic feedback: `.light` (suave, continuo cada 100ms). + - **Inválido** (viola reglas): + - Slot muestra borde rojo (#FF9999, 2px). + - Haptic feedback: `.rigid` (fuerte, único). + +3. **Drop**: + - **Válido**: + - Animación "plop": slot hace scale 1.0 → 1.1 → 1.0 (duration: 0.3s). + - Plato desaparece del drawer con fade out. + - Haptic feedback: `.success`. + - **Inválido**: + - Plato vuelve al drawer con spring animation (bouncy). + - Haptic feedback: `.error`. + +**Código ejemplo (SwiftUI)**: +```swift +.onLongPressGesture(minimumDuration: 0.2) { + // Iniciar drag + withAnimation(.spring()) { + draggedDish = dish + isLifted = true + } + HapticManager.shared.impact(style: .light) +} +.gesture( + DragGesture() + .onChanged { value in + dragOffset = value.translation + validateDrop(at: value.location) + } + .onEnded { value in + performDrop(at: value.location) + } +) +``` + +--- + +### 2. Completar Automáticamente + +**Flujo**: +1. Usuario tap en botón "Completar ✨". +2. Botón muestra loading indicator (ProgressView, color coral). +3. Algoritmo ejecuta en background (no bloquea UI). +4. Animación secuencial: + - Slots vacíos se rellenan uno por uno (delay: 150ms entre cada uno). + - Cada slot hace fade in + scale animation (0.8 → 1.0). +5. Al terminar: + - Confetti suave (partículas pequeñas, colores pastel, duration: 2s). + - Haptic feedback: `.success`. + - Toast: "¡Listo! Tu semana está completa 🎉" (auto-dismiss en 3s). + +**Código ejemplo (SwiftUI)**: +```swift +func autoComplete() { + isLoading = true + + Task { + let filledSlots = await MealPlanner.shared.autoComplete(weekPlan: weekPlan) + + for (index, slot) in filledSlots.enumerated() { + try? await Task.sleep(nanoseconds: 150_000_000) // 150ms + + withAnimation(.spring()) { + weekPlan.slots[slot.id] = slot + } + HapticManager.shared.impact(style: .light) + } + + isLoading = false + showConfetti = true + HapticManager.shared.notification(type: .success) + showToast = true + } +} +``` + +--- + +### 3. Resetear + +**Flujo**: +1. Usuario tap en botón "Resetear 🔄". +2. Alert suave (no modal agresivo): + ``` + ┌─────────────────────────────────────┐ + │ ¿Limpiar la semana completa? │ + │ │ + │ Esto no se puede deshacer │ + │ │ + │ [Cancelar] [Limpiar] │ + └─────────────────────────────────────┘ + ``` +3. Si usuario confirma: + - Todos los slots vacíos hacen fade out (simultáneo, duration: 0.3s). + - Haptic feedback: `.warning`. + - Platos vuelven al drawer con fade in (staggered, delay: 50ms entre cada uno). + +**Código ejemplo (SwiftUI)**: +```swift +func resetWeek() { + withAnimation(.easeOut(duration: 0.3)) { + for slot in weekPlan.slots { + slot.dish = nil + } + } + HapticManager.shared.notification(type: .warning) +} +``` + +--- + +### 4. Micro-interactions Adicionales + +#### 4.1: Empty State (sin platos) +- Ilustración de familia con plato vacío. +- Texto: "Añade tu primer plato para empezar". +- Botón "Añadir plato" (primary, centrado). + +#### 4.2: Pull to Refresh (en lista de platos) +- Arrastrar hacia abajo → loading indicator. +- Aunque no haya backend, simular recarga con delay 1s. + +#### 4.3: Swipe to Delete (en lista de platos) +- Swipe left → botón "Eliminar" (rojo, icono 🗑️). +- Tap en eliminar → animación de desaparición (slide left + fade out). + +#### 4.4: Toast Notifications +- Posición: top-center. +- Background: coral suave con opacity 0.95. +- Sombra suave. +- Auto-dismiss en 3s. +- Ejemplos: + - "Plato guardado ✓" + - "Evento eliminado del calendario" + - "No puedo completar con las reglas actuales" + +--- + +## Integración con Calendario Nativo + +### Permisos + +**Solicitar permiso** al activar sincronización por primera vez: +```swift +import EventKit + +let eventStore = EKEventStore() + +func requestCalendarAccess() async -> Bool { + do { + return try await eventStore.requestFullAccessToEvents() + } catch { + print("Error requesting calendar access: \(error)") + return false + } +} +``` + +**Si usuario deniega**: +- Mostrar alert: "MealMood necesita acceso a tu calendario para sincronizar eventos. Ve a Ajustes > MealMood > Calendario." +- Link directo a Settings del sistema: `UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)` + +--- + +### Crear Evento + +**Función**: +```swift +func createEvent( + slot: MealSlot, + dish: Dish, + weekStartDate: Date, + settings: Settings +) -> String? { + guard let calendarId = settings.calendarId, + let calendar = eventStore.calendar(withIdentifier: calendarId) else { + return nil + } + + let event = EKEvent(eventStore: eventStore) + + // Título + let prefix = settings.eventPrefix.isEmpty ? "" : "\(settings.eventPrefix) " + let mealName = slot.mealType == .lunch ? "Comida" : "Cena" + event.title = "\(prefix)\(mealName): \(dish.name)" + + // Fecha y hora + let slotDate = weekStartDate.addingDays(slot.dayOfWeek) + let slotTime = slot.mealType == .lunch ? settings.lunchTime : settings.dinnerTime + event.startDate = combine(date: slotDate, time: slotTime) + event.endDate = event.startDate.addingMinutes(settings.eventDuration) + + // Nota + event.notes = dish.description + + // Calendario + event.calendar = calendar + + // Alerta + if let reminderMinutes = settings.reminderMinutesBefore { + let alarm = EKAlarm(relativeOffset: -TimeInterval(reminderMinutes * 60)) + event.addAlarm(alarm) + } + + // Guardar + do { + try eventStore.save(event, span: .thisEvent) + return event.eventIdentifier + } catch { + print("Error saving event: \(error)") + return nil + } +} +``` + +--- + +### Eliminar Evento + +**Función**: +```swift +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)") + } +} +``` + +--- + +### Actualizar Eventos (cambio de hora) + +**Función**: +```swift +func updateEventsTime(weekPlan: WeekPlan, newLunchTime: Date, newDinnerTime: Date) { + for slot in weekPlan.slots { + guard let eventId = slot.calendarEventId, + let event = eventStore.event(withIdentifier: eventId), + event.startDate >= Date() else { // solo futuros + continue + } + + // Actualizar hora + let newTime = slot.mealType == .lunch ? newLunchTime : newDinnerTime + let newStartDate = combine(date: event.startDate, time: newTime) + event.startDate = newStartDate + event.endDate = newStartDate.addingMinutes(settings.eventDuration) + + // Guardar + do { + try eventStore.save(event, span: .thisEvent) + } catch { + print("Error updating event: \(error)") + } + } +} +``` + +--- + +### Helpers + +```swift +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 combine(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)! +} +``` + +--- + +## Algoritmo de Autocompletado + +### Pseudocódigo + +```python +def autocomplete(week_plan, available_dishes, tags_rules): + """ + Rellena slots vacíos respetando reglas de tags. + + Args: + week_plan: WeekPlan actual + available_dishes: [Dish] todos los platos del usuario + tags_rules: {tag_id: TagRules} reglas de cada tag + + Returns: + WeekPlan actualizado + """ + empty_slots = week_plan.get_empty_slots() + + for slot in empty_slots: + # Filtrar platos válidos + candidates = filter_valid_dishes( + dishes=available_dishes, + slot=slot, + current_plan=week_plan, + rules=tags_rules + ) + + if len(candidates) == 0: + continue # dejar slot vacío + + # Priorizar platos menos usados esta semana + dish = weighted_random_pick(candidates, week_plan) + week_plan.assign(slot, dish) + + return week_plan + + +def filter_valid_dishes(dishes, slot, current_plan, rules): + """Filtra platos que no violen reglas.""" + valid = [] + + for dish in dishes: + if not violates_rules(dish, slot, current_plan, rules): + valid.append(dish) + + return valid + + +def violates_rules(dish, slot, current_plan, rules): + """ + Verifica si asignar este plato al slot viola alguna regla. + + Returns: + True si viola alguna regla, False si es válido + """ + for tag_id in dish.tags: + tag_rule = rules[tag_id] + + # Regla 1: Max por semana + if tag_rule.maxPerWeek is not None: + count = current_plan.count_tag_usage(tag_id) + if count >= tag_rule.maxPerWeek: + return True + + # Regla 2: No consecutivo + if tag_rule.noConsecutive: + adjacent_slots = current_plan.get_adjacent_slots(slot) + for adj_slot in adjacent_slots: + if adj_slot.dish and tag_id in adj_slot.dish.tags: + return True + + # Regla 3: No duplicar en el mismo día + if tag_rule.noDuplicateInDay: + same_day_slots = current_plan.get_same_day_slots(slot) + for sd_slot in same_day_slots: + if sd_slot.dish and tag_id in sd_slot.dish.tags: + return True + + # Regla 4: Restricción de tipo de comida + if tag_rule.mealTypeRestriction is not None: + if slot.mealType != tag_rule.mealTypeRestriction: + return True + + return False + + +def weighted_random_pick(candidates, current_plan): + """ + Elige un plato aleatoriamente, priorizando los menos usados. + + Lógica: + - Platos con 0 usos: peso 3 + - Platos con 1 uso: peso 2 + - Platos con 2+ usos: peso 1 + """ + weights = [] + for dish in candidates: + usage_count = current_plan.count_dish_usage(dish.id) + + if usage_count == 0: + weight = 3 + elif usage_count == 1: + weight = 2 + else: + weight = 1 + + weights.append(weight) + + # Random weighted choice + import random + return random.choices(candidates, weights=weights)[0] +``` + +--- + +### Helpers + +```swift +extension WeekPlan { + func getEmptySlots() -> [MealSlot] { + slots.filter { $0.dish == nil } + } + + func countTagUsage(_ tagId: UUID) -> Int { + var count = 0 + for slot in slots { + if let dishId = slot.dish, + let dish = DishRepository.shared.getDish(by: dishId), + dish.tags.contains(tagId) { + count += 1 + } + } + return count + } + + func countDishUsage(_ dishId: UUID) -> Int { + slots.filter { $0.dish == dishId }.count + } + + func getAdjacentSlots(_ slot: MealSlot) -> [MealSlot] { + var adjacent: [MealSlot] = [] + + // Día anterior, mismo tipo de comida + if slot.dayOfWeek > 0 { + if let prevSlot = slots.first(where: { + $0.dayOfWeek == slot.dayOfWeek - 1 && $0.mealType == slot.mealType + }) { + adjacent.append(prevSlot) + } + } + + // Día siguiente, mismo tipo de comida + if slot.dayOfWeek < 6 { + if let nextSlot = slots.first(where: { + $0.dayOfWeek == slot.dayOfWeek + 1 && $0.mealType == slot.mealType + }) { + adjacent.append(nextSlot) + } + } + + return adjacent + } + + func getSameDaySlots(_ slot: MealSlot) -> [MealSlot] { + slots.filter { + $0.dayOfWeek == slot.dayOfWeek && $0.id != slot.id + } + } +} +``` + +--- + +## Sincronización y Persistencia + +### SwiftData (iOS 17+) + +#### Configuración + +```swift +import SwiftData + +@main +struct MealMoodApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + .modelContainer(for: [ + Settings.self, + Tag.self, + Dish.self, + WeekPlan.self, + MealSlot.self + ]) + } +} +``` + +#### Modelo de Datos + +```swift +@Model +final class Settings { + var mealWindows: String // "dinnerOnly", "lunchOnly", "both" + var includeWeekends: Bool + var language: String // "spanish", "english" + + var calendarId: String? + var syncEnabled: Bool + var lunchTime: Date + var dinnerTime: Date + var eventDuration: Int + var eventPrefix: String + var reminderMinutesBefore: Int? + + var isPremium: Bool + + init() { + self.mealWindows = "dinnerOnly" + self.includeWeekends = true + self.language = "spanish" + self.calendarId = nil + self.syncEnabled = false + self.lunchTime = Date() // set to 14:00 + self.dinnerTime = Date() // set to 21:00 + self.eventDuration = 60 + self.eventPrefix = "🍽️" + self.reminderMinutesBefore = nil + self.isPremium = false + } +} + +@Model +final class Tag { + var id: UUID + var name: String + var color: String + var maxPerWeek: Int? + var noConsecutive: Bool + var noDuplicateInDay: Bool + var mealTypeRestriction: String? // "lunch", "dinner", nil + + init(id: UUID = UUID(), name: String, color: String, maxPerWeek: Int? = nil, noConsecutive: Bool = false, noDuplicateInDay: Bool = false, mealTypeRestriction: String? = nil) { + self.id = id + self.name = name + self.color = color + self.maxPerWeek = maxPerWeek + self.noConsecutive = noConsecutive + self.noDuplicateInDay = noDuplicateInDay + self.mealTypeRestriction = mealTypeRestriction + } +} + +@Model +final class Dish { + 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 + } +} + +@Model +final class WeekPlan { + 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 + } +} + +@Model +final class MealSlot { + var id: UUID + var dayOfWeek: Int + var mealType: String // "lunch", "dinner" + var dishId: UUID? + var calendarEventId: String? + + init(id: UUID = UUID(), dayOfWeek: Int, mealType: String, dishId: UUID? = nil, calendarEventId: String? = nil) { + self.id = id + self.dayOfWeek = dayOfWeek + self.mealType = mealType + self.dishId = dishId + self.calendarEventId = calendarEventId + } +} +``` + +--- + +### CloudKit (Sincronización iCloud) + +#### Configuración + +1. Habilitar CloudKit en Xcode: + - Target → Signing & Capabilities → + Capability → iCloud. + - Activar "CloudKit". + - Crear container (ej: `iCloud.com.mealMood.app`). + +2. Configurar SwiftData para usar CloudKit: +```swift +@main +struct MealMoodApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + .modelContainer( + for: [Settings.self, Tag.self, Dish.self, WeekPlan.self, MealSlot.self], + isStoredInMemoryOnly: false, + isAutosaveEnabled: true, + isUndoEnabled: false, + cloudKitContainerIdentifier: "iCloud.com.mealMood.app" + ) + } +} +``` + +**Comportamiento**: +- Datos se sincronizan automáticamente entre dispositivos del mismo usuario. +- Sincronización en background (no requiere acción del usuario). +- Conflictos resueltos por "last write wins" (por defecto). + +--- + +## Monetización + +### Free Tier + +**Limitaciones**: +- Máximo 20 platos. +- Solo 10 tags predefinidos (no se pueden crear custom). +- Solo reglas básicas (max/semana, no consecutivo, no mismo día). +- Banner AdMob en parte inferior de Home. + +**AdMob Configuración**: +```swift +import GoogleMobileAds + +struct AdBannerView: UIViewRepresentable { + func makeUIView(context: Context) -> GADBannerView { + let banner = GADBannerView(adSize: GADAdSizeBanner) + banner.adUnitID = "ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX" // reemplazar con tu ID + banner.rootViewController = UIApplication.shared.windows.first?.rootViewController + banner.load(GADRequest()) + return banner + } + + func updateUIView(_ uiView: GADBannerView, context: Context) {} +} +``` + +**Ubicación**: +- Home → parte inferior, sticky. +- Background: #FFF9F5 (mismo color que la app). +- Altura: 50px. +- NO en otras pantallas (Settings, gestión de platos, etc.). + +--- + +### Premium Tier + +**Precio**: +- Mensual: $2.99/mes. +- Anual: $19.99/año (ahorro 44%). + +**Beneficios**: +- Sin publicidad. +- Platos ilimitados. +- Tags custom ilimitados. +- (Futuro) Reglas avanzadas. +- (Futuro) Múltiples planificaciones guardadas. +- (Futuro) Exportar a lista de compra. +- (Futuro) Analytics/histórico. + +**IAP Configuración**: +```swift +import StoreKit + +class StoreManager: ObservableObject { + @Published var products: [Product] = [] + @Published var isPremium: Bool = false + + private let productIds = [ + "com.mealmood.premium.monthly", + "com.mealmood.premium.yearly" + ] + + init() { + Task { + await loadProducts() + await checkPremiumStatus() + } + } + + func loadProducts() async { + do { + products = try await Product.products(for: productIds) + } catch { + print("Failed to load products: \(error)") + } + } + + func purchase(_ product: Product) async { + do { + let result = try await product.purchase() + + switch result { + case .success(let verification): + switch verification { + case .verified(let transaction): + await transaction.finish() + isPremium = true + case .unverified: + break + } + case .userCancelled, .pending: + break + @unknown default: + break + } + } catch { + print("Purchase failed: \(error)") + } + } + + func restorePurchases() async { + do { + try await AppStore.sync() + await checkPremiumStatus() + } catch { + print("Restore failed: \(error)") + } + } + + private func checkPremiumStatus() async { + for await result in Transaction.currentEntitlements { + if case .verified(let transaction) = result, + productIds.contains(transaction.productID) { + isPremium = true + return + } + } + isPremium = false + } +} +``` + +--- + +## Stack Técnico + +### Plataforma +- **iOS**: mínimo iOS 17.0 (para SwiftData). +- **Dispositivos**: iPhone (optimizado para todos los tamaños). +- **Orientación**: Portrait only (landscape no necesario para MVP). + +### Lenguajes y Frameworks +- **Swift**: 5.9+. +- **SwiftUI**: para toda la UI. +- **SwiftData**: persistencia local. +- **EventKit**: integración con calendario nativo. +- **CloudKit**: sincronización iCloud. +- **StoreKit 2**: IAP (In-App Purchases). + +### Dependencias Externas +- **Google Mobile Ads SDK**: para AdMob. + - Instalación: Swift Package Manager (SPM) o CocoaPods. + - URL: `https://github.com/googleads/swift-package-manager-google-mobile-ads` + +### Arquitectura +- **MVVM** (Model-View-ViewModel): + - **Models**: Settings, Tag, Dish, WeekPlan, MealSlot (SwiftData models). + - **ViewModels**: HomeViewModel, DishViewModel, SettingsViewModel, etc. + - **Views**: SwiftUI views. + +### Testing +- **Unit tests**: lógica de algoritmo de autocompletado, validación de reglas. +- **UI tests**: flujos críticos (onboarding, drag & drop, crear plato). + +--- + +## Plan de Implementación (Sprints) + +### Sprint 1: Setup e Infraestructura (1 semana) +- Configurar proyecto Xcode. +- Configurar SwiftData models. +- Configurar CloudKit. +- Crear paleta de colores y componentes UI reutilizables. +- Setup Google AdMob. + +**Entregables**: +- Proyecto compilable con estructura de carpetas. +- Modelos de datos persistentes. +- Componentes UI básicos (buttons, cards, pills). + +--- + +### Sprint 2: Onboarding (1 semana) +- Pantalla de bienvenida. +- 4 pasos de configuración inicial. +- Navegación fluida entre pasos. +- Crear tags predefinidos en primera carga. + +**Entregables**: +- Onboarding completo y funcional. +- Usuario puede completar setup inicial. + +--- + +### Sprint 3: Gestión de Platos (1 semana) +- Pantalla de lista de platos. +- Crear/editar/eliminar platos. +- Selector de tags (modal). +- Validaciones de formulario. + +**Entregables**: +- Usuario puede crear y gestionar platos. +- Lista de platos persistente. + +--- + +### Sprint 4: Pantalla Principal (Calendario) (2 semanas) +- Layout de calendario semanal. +- Slots vacíos y llenos. +- Drawer de platos. +- Drag & drop básico (sin validaciones). +- Navegación entre semanas. + +**Entregables**: +- Pantalla Home funcional. +- Usuario puede asignar platos manualmente. + +--- + +### Sprint 5: Validación de Reglas y Drag & Drop Avanzado (1 semana) +- Implementar lógica de validación de reglas. +- Feedback visual en drag & drop (bordes verdes/rojos). +- Haptic feedback. +- Animaciones. + +**Entregables**: +- Drag & drop valida reglas en tiempo real. +- Feedback visual y táctil funcional. + +--- + +### Sprint 6: Autocompletado (1 semana) +- Implementar algoritmo de autocompletado. +- Animación secuencial al rellenar slots. +- Confetti y toast. +- Botón "Resetear" con confirmación. + +**Entregables**: +- Botón "Completar" funcional. +- Usuario puede autocompletar semana respetando reglas. + +--- + +### Sprint 7: Gestión de Tags (1 semana) +- Pantalla de lista de tags. +- Editar reglas de tags. +- Localización de tags predefinidos. + +**Entregables**: +- Usuario puede modificar reglas de tags. +- Cambios se aplican al algoritmo. + +--- + +### Sprint 8: Sincronización con Calendario (1 semana) +- Solicitar permiso de EventKit. +- Crear/eliminar eventos al asignar/quitar platos. +- Configuración en Settings (horarios, prefijo, etc.). +- Actualizar eventos al cambiar hora. + +**Entregables**: +- Sincronización con calendario funcional. +- Eventos visibles en app Calendario nativa. + +--- + +### Sprint 9: Settings y Multiidioma (1 semana) +- Pantalla de Settings completa. +- Cambio de idioma (español/inglés). +- Localización de toda la app. +- Persistencia de configuración. + +**Entregables**: +- Settings funcional. +- App totalmente bilingüe. + +--- + +### Sprint 10: IAP y Premium (1 semana) +- Configurar StoreKit. +- Pantalla de Premium con planes. +- Compra y restauración. +- Limitar features en Free. + +**Entregables**: +- Usuario puede comprar Premium. +- Features Free/Premium funcionan correctamente. + +--- + +### Sprint 11: Testing y QA (1 semana) +- Unit tests para algoritmo. +- UI tests para flujos críticos. +- Bug fixing. +- Performance optimization. + +**Entregables**: +- App estable, sin bugs críticos. +- Cobertura de tests >70%. + +--- + +### Sprint 12: Polish y Lanzamiento (1 semana) +- Ajustes finales de UI/UX. +- App Store assets (screenshots, descripción, keywords). +- Beta testing con TestFlight. +- Envío a App Store. + +**Entregables**: +- App publicada en App Store. + +--- + +## Anexos + +### A. Glosario + +- **Slot**: espacio en el calendario donde se asigna una comida (comida o cena de un día específico). +- **Tag**: etiqueta asociada a un plato (ej: Carne, Pescado, Sin Gluten). +- **Tag Rule**: regla asociada a un tag (ej: máximo 3 veces por semana). +- **Autocompletado**: algoritmo que rellena slots vacíos respetando reglas. +- **Drag & Drop**: interacción de arrastrar un plato y soltarlo en un slot. +- **EventKit**: framework de iOS para interactuar con el calendario nativo. +- **SwiftData**: framework de Apple para persistencia local (iOS 17+). +- **CloudKit**: servicio de Apple para sincronización en la nube. +- **IAP**: In-App Purchase (compra dentro de la app). + +--- + +### B. Referencias Técnicas + +#### SwiftUI Drag & Drop +- Documentación: https://developer.apple.com/documentation/swiftui/view/ondrag(_:) +- Tutorial: https://www.hackingwithswift.com/quick-start/swiftui/how-to-support-drag-and-drop-in-swiftui + +#### EventKit +- Documentación: https://developer.apple.com/documentation/eventkit +- Tutorial: https://www.hackingwithswift.com/example-code/system/how-to-add-events-to-the-users-calendar-using-eventkit + +#### SwiftData +- Documentación: https://developer.apple.com/documentation/swiftdata +- Tutorial: https://www.hackingwithswift.com/quick-start/swiftdata + +#### StoreKit 2 +- Documentación: https://developer.apple.com/documentation/storekit +- Tutorial: https://www.hackingwithswift.com/quick-start/storekit + +#### Google Mobile Ads +- Documentación: https://developers.google.com/admob/ios/quick-start +- Setup: https://developers.google.com/admob/ios/quick-start + +--- + +### C. Notas de Implementación + +#### Consideraciones de Performance +- **SwiftData queries**: usar `@Query` con predicates para filtrar eficientemente. +- **Drag & Drop**: mantener 60fps, usar `LazyVStack` en lista de platos. +- **Algoritmo de autocompletado**: ejecutar en background thread (`Task`), no bloquear UI. +- **Sincronización CloudKit**: usar queue de operaciones para batch updates. + +#### Consideraciones de UX +- **Feedback inmediato**: haptic feedback en cada interacción importante. +- **Animaciones suaves**: spring animations (0.3-0.5s duration). +- **Loading states**: mostrar ProgressView durante operaciones lentas. +- **Empty states**: ilustraciones amigables, no solo texto. +- **Error handling**: mostrar alerts/toasts amigables, no errores técnicos. + +#### Consideraciones de Accesibilidad +- **VoiceOver**: etiquetar todos los elementos interactivos. +- **Dynamic Type**: usar `@ScaledMetric` para tamaños de fuente. +- **Color Contrast**: asegurar ratio mínimo 4.5:1 (WCAG AA). +- **Haptic Feedback**: opcional, respetar configuración del sistema. + +--- + +### D. Checklist Pre-Lanzamiento + +#### Funcionalidad +- [ ] Onboarding completo y sin bugs. +- [ ] Crear/editar/eliminar platos funcional. +- [ ] Drag & drop fluido (60fps). +- [ ] Validación de reglas correcta. +- [ ] Autocompletado respeta reglas. +- [ ] Sincronización con calendario funcional. +- [ ] Settings guarda cambios correctamente. +- [ ] Multiidioma (ES/EN) sin strings faltantes. +- [ ] IAP (compra y restauración) funcional. +- [ ] AdMob muestra ads correctamente. + +#### Testing +- [ ] Unit tests para algoritmo (>80% cobertura). +- [ ] UI tests para flujos críticos. +- [ ] Testeo manual en iPhone SE, iPhone 15 Pro, iPhone 15 Pro Max. +- [ ] Beta testing con 10+ usuarios externos (TestFlight). +- [ ] Performance: app carga en <2s, drag & drop sin lag. +- [ ] Memoria: no memory leaks (Instruments). + +#### App Store +- [ ] Screenshots en 3 tamaños (6.7", 6.5", 5.5"). +- [ ] Video preview (opcional, recomendado). +- [ ] Descripción optimizada (keywords: meal planner, family meals, weekly menu). +- [ ] Privacy policy URL. +- [ ] Support URL. +- [ ] Categoría: Food & Drink. +- [ ] Keywords: meal planner, weekly menu, family meals, meal organizer, recipe planner. + +#### Legal +- [ ] Privacy policy publicada (mencionar EventKit, CloudKit, AdMob). +- [ ] Terms of Service (opcional, recomendado). +- [ ] App Store Review Guidelines (revisar compliance). + +--- + +## Fin del PRD + +**Versión**: 1.0 +**Fecha**: 2025-02-16 +**Autor**: Alex + Claude +**Próxima revisión**: Post-Sprint 6 (evaluar ajustes de MVP) + +--- + +## Notas para Claude Code + +Este PRD está diseñado para ser consumido por Claude Code. Contiene: + +1. **Especificaciones completas** de funcionalidades MVP y Freemium. +2. **Modelo de datos** con Swift structs/classes. +3. **Ejemplos de código** para componentes clave (drag & drop, EventKit, SwiftData). +4. **Wireframes textuales** detallados de cada pantalla. +5. **Paleta de colores** y componentes UI reutilizables. +6. **Plan de implementación** en sprints secuenciales. + +**Instrucciones para Claude Code**: +- Implementar MVP siguiendo orden de sprints. +- Usar SwiftUI + SwiftData (iOS 17+). +- Priorizar UX fluida (animaciones, haptic feedback). +- Código limpio, comentado, siguiendo Swift style guide. +- Tests unitarios para lógica crítica (algoritmo, validaciones). + +**Contacto**: Si hay dudas o ambigüedades, consultar con el product owner (Alex). + +--- + +¡Buena suerte con la implementación! 🚀✨ diff --git a/TestPremium.storekit b/TestPremium.storekit new file mode 100644 index 0000000..2f248f4 --- /dev/null +++ b/TestPremium.storekit @@ -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 + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..af00595 --- /dev/null +++ b/project.yml @@ -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"