From 73c20393389be1fa426dfff94e52f183a2a6c38a Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Fri, 3 Jul 2026 10:08:04 +0200 Subject: [PATCH] 1.2.0: contextual notifications + in-onboarding auto-fill (aha moment) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New "Week Ready" onboarding step: auto-fills the first week inline right after dish creation, then pitches the Sunday reminder with context before requesting OS notification permission (no more cold prompt on Home) - Notifications section in Settings: weekly reminder toggle, deep-link to system settings when permission is denied - NotificationService: remindersEnabled preference, requestPermission(), authorizationStatus(), cancelAllReminders() - Post-onboarding premium prompt deferred to 2nd session (paywall was just shown as the final onboarding step — 2s-later alert was prompt fatigue) - Back navigation blocked after onboarding commit to avoid duplicate dishes - 10 new strings × 6 languages Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UtWT52pAE91D7mbDda1cAM --- MealMood.xcodeproj/project.pbxproj | 84 +++++++------ .../Resources/de.lproj/Localizable.strings | 14 +++ .../Resources/en.lproj/Localizable.strings | 14 +++ .../Resources/es.lproj/Localizable.strings | 14 +++ .../Resources/fr.lproj/Localizable.strings | 14 +++ .../Resources/it.lproj/Localizable.strings | 14 +++ .../Resources/pt-BR.lproj/Localizable.strings | 14 +++ MealMood/Services/NotificationService.swift | 46 +++++++ MealMood/ViewModels/OnboardingViewModel.swift | 50 +++++++- MealMood/Views/Home/HomeView.swift | 10 +- .../Views/Onboarding/OnboardingView.swift | 14 ++- .../Views/Onboarding/WeekReadyStepView.swift | 115 ++++++++++++++++++ MealMood/Views/Settings/SettingsView.swift | 61 ++++++++++ 13 files changed, 415 insertions(+), 49 deletions(-) create mode 100644 MealMood/Views/Onboarding/WeekReadyStepView.swift diff --git a/MealMood.xcodeproj/project.pbxproj b/MealMood.xcodeproj/project.pbxproj index 1e34d36..4e69e27 100644 --- a/MealMood.xcodeproj/project.pbxproj +++ b/MealMood.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 17E1054EE183486E7BE38C19 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6FC2153490D53B80DBDC034 /* WidgetDataStore.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 */; }; + 2124FD777418A4EEDF64BDC3 /* WeekReadyStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D954415A7AC73D41309D2BDF /* WeekReadyStepView.swift */; }; 233B9C1A5CA7E938318491C0 /* SocialShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1975C49FFC7D91AEB7932C97 /* SocialShareSheet.swift */; }; 2519A55C6BDA686B6F85E79D /* View+MealCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC01EE9D78D980727A644CDF /* View+MealCard.swift */; }; 25B3C328E088A403CABAF20A /* PremiumSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 928E6D4F6D78AD1840245FB4 /* PremiumSyncService.swift */; }; @@ -24,6 +25,7 @@ 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 */; }; + 443CC3997A9A4818B51066FB /* StatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8AD94FC874467D863D464C /* StatsView.swift */; }; 478AE18DA0F887D3D5164635 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C77C350D4A839B76A3D2EFE7 /* Foundation.framework */; }; 48F9D3308375F820B2B88D1B /* PremiumUpsellBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CFA001E1BFA916ABC4C44C1 /* PremiumUpsellBanner.swift */; }; 4D53B5101AE845D7C03A0EB4 /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = 192E7CF40FD34C3FF4DDFDA3 /* GoogleMobileAds */; }; @@ -61,12 +63,13 @@ 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 */; }; - C7B1F5A21B5E4A9E91B7F5A2 /* BottomPromoBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E2F6A3F47E5C9F92A8E6B3 /* BottomPromoBanner.swift */; }; + B4D6F8012C3E5F7A91B3D5F7 /* FeedbackStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.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 */; }; C3CB4ACC213DAAD25E610C56 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB4FC89C90928DD833240244 /* WidgetKit.framework */; }; C565A1975302748A2B2152BE /* DishViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8472961AE65E2A3460B67C3 /* DishViewModel.swift */; }; + C7B1F5A21B5E4A9E91B7F5A2 /* BottomPromoBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E2F6A3F47E5C9F92A8E6B3 /* BottomPromoBanner.swift */; }; C81C935386BE1E939DA55F8D /* MealWindowsStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA11C8A81D5C0CA8A3C13D /* MealWindowsStepView.swift */; }; CD174669421FE1FD4315F6E0 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BB0D740B79B9E129A3C2F382 /* WidgetKit.framework */; }; CD2ADCDADFEC2D562E23D8E6 /* AutocompleteEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7AB2ECCA7D330E50AC549E /* AutocompleteEngine.swift */; }; @@ -74,11 +77,11 @@ 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 */; }; - E5D9C4B783A14E2B9F4D2E68 /* PaywallStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F61A8C92B47D5938C61E9A75 /* PaywallStepView.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 */; }; + E5D9C4B783A14E2B9F4D2E68 /* PaywallStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F61A8C92B47D5938C61E9A75 /* PaywallStepView.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 */; }; @@ -87,8 +90,6 @@ FB0011111111111111111106 /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB0011111111111111111107 /* AnalyticsService.swift */; }; FB0011111111111111111108 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = FB001111111111111111110B /* FirebaseCrashlytics */; }; FB0011111111111111111109 /* CrashlyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB001111111111111111110A /* CrashlyticsService.swift */; }; - 443CC3997A9A4818B51066FB /* StatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8AD94FC874467D863D464C /* StatsView.swift */; }; - B4D6F8012C3E5F7A91B3D5F7 /* FeedbackStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -121,18 +122,17 @@ 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 = ""; }; - D8E2F6A3F47E5C9F92A8E6B3 /* BottomPromoBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomPromoBanner.swift; sourceTree = ""; }; 065982B284538632951F1C3A /* MealMoodLogo.svg */ = {isa = PBXFileReference; lastKnownFileType = text; path = MealMoodLogo.svg; sourceTree = ""; }; 0EC555FD2F4498780005513D /* aaaa.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = aaaa.storekit; 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 = ""; }; - F61A8C92B47D5938C61E9A75 /* PaywallStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallStepView.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 = ""; }; 1975C49FFC7D91AEB7932C97 /* SocialShareSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SocialShareSheet.swift; sourceTree = ""; }; + 1E8AD94FC874467D863D464C /* StatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsView.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 = ""; }; @@ -167,6 +167,7 @@ 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 = ""; }; + A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackStore.swift; sourceTree = ""; }; A6FC2153490D53B80DBDC034 /* WidgetDataStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = ""; }; AB771E85924AE086647A5113 /* Color+MealMood.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+MealMood.swift"; sourceTree = ""; }; B042BB2F81973D854E84BABD /* MealMoodWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MealMoodWidget.swift; sourceTree = ""; }; @@ -186,6 +187,8 @@ 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 = ""; }; + D8E2F6A3F47E5C9F92A8E6B3 /* BottomPromoBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomPromoBanner.swift; sourceTree = ""; }; + D954415A7AC73D41309D2BDF /* WeekReadyStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WeekReadyStepView.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 = ""; }; E18A2F6B56D64C1EBC91E219 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; @@ -195,6 +198,7 @@ E8825212ACFA0D3F6C716E94 /* ReviewPromptService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPromptService.swift; sourceTree = ""; }; EB4FC89C90928DD833240244 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; F0200C0C11C912711C724558 /* PremiumAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumAccess.swift; sourceTree = ""; }; + F61A8C92B47D5938C61E9A75 /* PaywallStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallStepView.swift; sourceTree = ""; }; F766AA7898D6F391DE1B9A81 /* MealMoodTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MealMoodTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; FB0011111111111111111103 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; FB0011111111111111111107 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; @@ -204,8 +208,6 @@ 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 = ""; }; - 1E8AD94FC874467D863D464C /* StatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsView.swift; sourceTree = ""; }; - A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackStore.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -256,6 +258,14 @@ path = Home; sourceTree = ""; }; + 0D63499EC79D43728D1CA8C7 /* Stats */ = { + isa = PBXGroup; + children = ( + 1E8AD94FC874467D863D464C /* StatsView.swift */, + ); + path = Stats; + sourceTree = ""; + }; 2BC91A087BC03FF7810C529F /* MealMoodWidget */ = { isa = PBXGroup; children = ( @@ -370,14 +380,6 @@ path = Views; sourceTree = ""; }; - 0D63499EC79D43728D1CA8C7 /* Stats */ = { - isa = PBXGroup; - children = ( - 1E8AD94FC874467D863D464C /* StatsView.swift */, - ); - path = Stats; - sourceTree = ""; - }; 8BE925662F55707DF083677A /* Dishes */ = { isa = PBXGroup; children = ( @@ -437,6 +439,7 @@ F61A8C92B47D5938C61E9A75 /* PaywallStepView.swift */, 035C6C0B728EB0EA80394CDD /* WeekendsStepView.swift */, 70D140A6E835CA6227C2D4D9 /* WelcomeStepView.swift */, + D954415A7AC73D41309D2BDF /* WeekReadyStepView.swift */, ); path = Onboarding; sourceTree = ""; @@ -621,6 +624,30 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + FB001111111111111111110C /* Upload dSYM to Crashlytics */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}", + "$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)", + ); + name = "Upload dSYM to Crashlytics"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${BUILD_DIR%Build/*}SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ A6B405631E28429E34B68811 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -709,35 +736,12 @@ FB0011111111111111111109 /* CrashlyticsService.swift in Sources */, 443CC3997A9A4818B51066FB /* StatsView.swift in Sources */, B4D6F8012C3E5F7A91B3D5F7 /* FeedbackStore.swift in Sources */, + 2124FD777418A4EEDF64BDC3 /* WeekReadyStepView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ -/* Begin PBXShellScriptBuildPhase section */ - FB001111111111111111110C /* Upload dSYM to Crashlytics */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}", - "$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)", - ); - name = "Upload dSYM to Crashlytics"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${BUILD_DIR%Build/*}SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\n"; - }; -/* End PBXShellScriptBuildPhase section */ - /* Begin PBXTargetDependency section */ C4E8F43173BDFCAE4D6DFF49 /* PBXTargetDependency */ = { isa = PBXTargetDependency; diff --git a/MealMood/Resources/de.lproj/Localizable.strings b/MealMood/Resources/de.lproj/Localizable.strings index db573c1..ea97000 100644 --- a/MealMood/Resources/de.lproj/Localizable.strings +++ b/MealMood/Resources/de.lproj/Localizable.strings @@ -362,3 +362,17 @@ /* Week rating */ "week_rating_prompt" = "Wie war diese Woche?"; + +/* Week ready onboarding step (1.2.0) */ +"onboarding_week_ready_title" = "Deine Woche ist fertig!"; +"onboarding_week_ready_subtitle" = "%d Mahlzeiten automatisch geplant. Du kannst später alles anpassen."; +"onboarding_week_ready_subtitle_empty" = "Dein Wochenplan ist eingerichtet. Füge jederzeit Gerichte hinzu und fülle ihn mit einem Tipp."; +"onboarding_reminder_pitch" = "Erhalte jeden Sonntag eine Erinnerung, damit die Woche immer geplant beginnt."; +"onboarding_reminder_allow" = "Sonntags erinnern"; +"onboarding_reminder_skip" = "Jetzt nicht"; + +/* Notification settings (1.2.0) */ +"settings_notifications_header" = "Mitteilungen"; +"settings_weekly_reminder" = "Wöchentliche Planungserinnerung"; +"settings_notifications_denied_hint" = "Mitteilungen sind in den Systemeinstellungen deaktiviert."; +"settings_open_system_settings" = "Einstellungen öffnen"; diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index 1e5f283..4e74bce 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -362,3 +362,17 @@ /* Week rating */ "week_rating_prompt" = "How was this week?"; + +/* Week ready onboarding step (1.2.0) */ +"onboarding_week_ready_title" = "Your week is ready!"; +"onboarding_week_ready_subtitle" = "%d meals planned automatically. You can tweak anything later."; +"onboarding_week_ready_subtitle_empty" = "Your weekly plan is set up. Add dishes anytime and fill it in one tap."; +"onboarding_reminder_pitch" = "Get a gentle reminder every Sunday so next week is always planned before it starts."; +"onboarding_reminder_allow" = "Remind me on Sundays"; +"onboarding_reminder_skip" = "Not now"; + +/* Notification settings (1.2.0) */ +"settings_notifications_header" = "Notifications"; +"settings_weekly_reminder" = "Weekly planning reminder"; +"settings_notifications_denied_hint" = "Notifications are disabled in system settings."; +"settings_open_system_settings" = "Open Settings"; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index 0d3286c..20578c6 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -362,3 +362,17 @@ /* Week rating */ "week_rating_prompt" = "¿Qué tal fue la semana?"; + +/* Week ready onboarding step (1.2.0) */ +"onboarding_week_ready_title" = "¡Tu semana está lista!"; +"onboarding_week_ready_subtitle" = "%d comidas planificadas automáticamente. Podrás ajustarlo todo después."; +"onboarding_week_ready_subtitle_empty" = "Tu plan semanal está preparado. Añade platos cuando quieras y complétalo con un toque."; +"onboarding_reminder_pitch" = "Recibe un recordatorio cada domingo para que la semana siempre empiece planificada."; +"onboarding_reminder_allow" = "Recuérdamelo los domingos"; +"onboarding_reminder_skip" = "Ahora no"; + +/* Notification settings (1.2.0) */ +"settings_notifications_header" = "Notificaciones"; +"settings_weekly_reminder" = "Recordatorio semanal de planificación"; +"settings_notifications_denied_hint" = "Las notificaciones están desactivadas en los ajustes del sistema."; +"settings_open_system_settings" = "Abrir Ajustes"; diff --git a/MealMood/Resources/fr.lproj/Localizable.strings b/MealMood/Resources/fr.lproj/Localizable.strings index 57b83ef..41399b2 100644 --- a/MealMood/Resources/fr.lproj/Localizable.strings +++ b/MealMood/Resources/fr.lproj/Localizable.strings @@ -362,3 +362,17 @@ /* Week rating */ "week_rating_prompt" = "Comment s'est passée cette semaine ?"; + +/* Week ready onboarding step (1.2.0) */ +"onboarding_week_ready_title" = "Votre semaine est prête !"; +"onboarding_week_ready_subtitle" = "%d repas planifiés automatiquement. Vous pourrez tout ajuster ensuite."; +"onboarding_week_ready_subtitle_empty" = "Votre planning hebdomadaire est prêt. Ajoutez des plats quand vous voulez et remplissez-le en un geste."; +"onboarding_reminder_pitch" = "Recevez un rappel chaque dimanche pour que la semaine commence toujours planifiée."; +"onboarding_reminder_allow" = "Rappelez-le-moi le dimanche"; +"onboarding_reminder_skip" = "Pas maintenant"; + +/* Notification settings (1.2.0) */ +"settings_notifications_header" = "Notifications"; +"settings_weekly_reminder" = "Rappel hebdomadaire de planification"; +"settings_notifications_denied_hint" = "Les notifications sont désactivées dans les réglages du système."; +"settings_open_system_settings" = "Ouvrir Réglages"; diff --git a/MealMood/Resources/it.lproj/Localizable.strings b/MealMood/Resources/it.lproj/Localizable.strings index 3a85714..99c165c 100644 --- a/MealMood/Resources/it.lproj/Localizable.strings +++ b/MealMood/Resources/it.lproj/Localizable.strings @@ -362,3 +362,17 @@ /* Week rating */ "week_rating_prompt" = "Com'è andata questa settimana?"; + +/* Week ready onboarding step (1.2.0) */ +"onboarding_week_ready_title" = "La tua settimana è pronta!"; +"onboarding_week_ready_subtitle" = "%d pasti pianificati automaticamente. Potrai modificare tutto in seguito."; +"onboarding_week_ready_subtitle_empty" = "Il tuo piano settimanale è pronto. Aggiungi piatti quando vuoi e completalo con un tocco."; +"onboarding_reminder_pitch" = "Ricevi un promemoria ogni domenica così la settimana inizia sempre pianificata."; +"onboarding_reminder_allow" = "Ricordamelo la domenica"; +"onboarding_reminder_skip" = "Non ora"; + +/* Notification settings (1.2.0) */ +"settings_notifications_header" = "Notifiche"; +"settings_weekly_reminder" = "Promemoria settimanale di pianificazione"; +"settings_notifications_denied_hint" = "Le notifiche sono disattivate nelle impostazioni di sistema."; +"settings_open_system_settings" = "Apri Impostazioni"; diff --git a/MealMood/Resources/pt-BR.lproj/Localizable.strings b/MealMood/Resources/pt-BR.lproj/Localizable.strings index 4048619..1e02143 100644 --- a/MealMood/Resources/pt-BR.lproj/Localizable.strings +++ b/MealMood/Resources/pt-BR.lproj/Localizable.strings @@ -362,3 +362,17 @@ /* Week rating */ "week_rating_prompt" = "Como foi esta semana?"; + +/* Week ready onboarding step (1.2.0) */ +"onboarding_week_ready_title" = "Sua semana está pronta!"; +"onboarding_week_ready_subtitle" = "%d refeições planejadas automaticamente. Você pode ajustar tudo depois."; +"onboarding_week_ready_subtitle_empty" = "Seu plano semanal está pronto. Adicione pratos quando quiser e preencha com um toque."; +"onboarding_reminder_pitch" = "Receba um lembrete todo domingo para que a semana sempre comece planejada."; +"onboarding_reminder_allow" = "Lembrar aos domingos"; +"onboarding_reminder_skip" = "Agora não"; + +/* Notification settings (1.2.0) */ +"settings_notifications_header" = "Notificações"; +"settings_weekly_reminder" = "Lembrete semanal de planejamento"; +"settings_notifications_denied_hint" = "As notificações estão desativadas nos ajustes do sistema."; +"settings_open_system_settings" = "Abrir Ajustes"; diff --git a/MealMood/Services/NotificationService.swift b/MealMood/Services/NotificationService.swift index 5d977d3..f0e425e 100644 --- a/MealMood/Services/NotificationService.swift +++ b/MealMood/Services/NotificationService.swift @@ -4,9 +4,46 @@ import UserNotifications @MainActor final class NotificationService { static let shared = NotificationService() + static let remindersEnabledKey = "notifications_reminders_enabled" + + private static let allReminderIdentifiers = [ + "mealmood.next-week-planning", + "mealmood.sunday-planning" + ] private init() {} + /// User-level preference for weekly planning reminders (independent of OS permission). + var remindersEnabled: Bool { + get { + UserDefaults.standard.object(forKey: Self.remindersEnabledKey) as? Bool ?? true + } + set { + UserDefaults.standard.set(newValue, forKey: Self.remindersEnabledKey) + if !newValue { cancelAllReminders() } + } + } + + func authorizationStatus() async -> UNAuthorizationStatus { + await UNUserNotificationCenter.current().notificationSettings().authorizationStatus + } + + /// Explicit permission request for contextual prompts (onboarding, settings toggle). + /// Returns whether notifications are authorized after the request. + @discardableResult + func requestPermission() async -> Bool { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + switch settings.authorizationStatus { + case .notDetermined: + return (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false + case .authorized, .provisional, .ephemeral: + return true + default: + return false + } + } + func requestPermissionIfNeeded() async { let center = UNUserNotificationCenter.current() let settings = await center.notificationSettings() @@ -14,7 +51,16 @@ final class NotificationService { _ = try? await center.requestAuthorization(options: [.alert, .sound, .badge]) } + func cancelAllReminders() { + UNUserNotificationCenter.current() + .removePendingNotificationRequests(withIdentifiers: Self.allReminderIdentifiers) + } + func schedulePlanningReminderIfNeeded(nextWeekPlan: WeekPlan?, language: AppLanguage) { + guard remindersEnabled else { + cancelAllReminders() + return + } let center = UNUserNotificationCenter.current() let identifier = "mealmood.next-week-planning" diff --git a/MealMood/ViewModels/OnboardingViewModel.swift b/MealMood/ViewModels/OnboardingViewModel.swift index eb6c24b..e59610c 100644 --- a/MealMood/ViewModels/OnboardingViewModel.swift +++ b/MealMood/ViewModels/OnboardingViewModel.swift @@ -28,7 +28,10 @@ final class OnboardingViewModel: ObservableObject { @Published var addedDishes: [(name: String, tagIds: [UUID])] = [] let maxOnboardingDishes = 10 - let totalSteps = 6 + let totalSteps = 7 + + /// Slots filled by the in-onboarding auto-fill (aha moment step). + @Published var autoFilledCount: Int = 0 var canContinue: Bool { switch currentStep { @@ -37,7 +40,8 @@ final class OnboardingViewModel: ObservableObject { case 2: return true // Weekends (toggle) case 3: return true // Calendar (optional) case 4: return true // Dishes are optional - case 5: return true // Paywall (always skippable) + case 5: return true // Week ready (aha moment + reminder opt-in) + case 6: return true // Paywall (always skippable) default: return false } } @@ -161,10 +165,9 @@ final class OnboardingViewModel: ObservableObject { AnalyticsService.logDishAdded(tagCount: dishData.tagIds.count, source: "onboarding") } AnalyticsService.logOnboardingCompleted(dishCount: onboardingDishCount) - // If the user added dishes, auto-fill their first week on launch so they - // land on a ready plan instead of an empty one. Otherwise fall back to the - // home auto-assign prompt. - UserDefaults.standard.set(createdDishes, forKey: Self.pendingAutoFillOnLaunchKey) + // Auto-fill now happens inline in the Week Ready onboarding step, so the + // launch flag stays off. Users without dishes still get the home prompt. + UserDefaults.standard.set(false, forKey: Self.pendingAutoFillOnLaunchKey) UserDefaults.standard.set(!createdDishes, forKey: Self.pendingAutoAssignPromptKey) UserDefaults.standard.set(true, forKey: Self.pendingPremiumPromptKey) return true @@ -173,4 +176,39 @@ final class OnboardingViewModel: ObservableObject { return false } } + + /// Fills the first week right inside onboarding (the "aha moment" step) using + /// the same engine as the home auto-complete. Returns the number of filled slots. + @discardableResult + func autoFillFirstWeek(context: ModelContext) -> Int { + autoFilledCount = 0 + guard !addedDishes.isEmpty else { return 0 } + + let dishes = (try? context.fetch(FetchDescriptor())) ?? [] + let tags = (try? context.fetch(FetchDescriptor())) ?? [] + let weekStart = Date().startOfWeek() + let plans = (try? context.fetch(FetchDescriptor())) ?? [] + guard let plan = plans.first(where: { $0.weekStartDate == weekStart }) else { return 0 } + + let emptySlots = plan.slots.filter { $0.dishId == nil && !$0.isEatingOut } + guard !emptySlots.isEmpty else { return 0 } + + let result = AutocompleteEngine.autocomplete( + emptySlots: emptySlots, + currentPlan: plan, + allDishes: dishes, + allTags: tags, + recentPlans: [], + rejectionCounts: FeedbackStore.rejectionCounts() + ) + plan.updatedAt = Date() + try? context.save() + + AnalyticsService.logAutoAssignUsed( + filledSlots: result.filledSlots.count, + unfilledSlots: result.unfilledCount + ) + autoFilledCount = result.filledSlots.count + return result.filledSlots.count + } } diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index 4b01bf8..a690590 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -515,7 +515,8 @@ struct HomeView: View { StatsView(weekPlans: weekPlans, allDishes: dishes, allTags: tags, language: settings.languageEnum.resolved()) } .task { - await NotificationService.shared.requestPermissionIfNeeded() + // Permission is now requested contextually (onboarding Week Ready step or + // the Settings toggle) — no cold OS prompt on first Home load. let nextPlan = fetchWeekPlan(for: Date().startOfWeek().addingDays(7)) NotificationService.shared.schedulePlanningReminderIfNeeded( nextWeekPlan: nextPlan, @@ -986,6 +987,13 @@ struct HomeView: View { let shouldShowPremiumPrompt = UserDefaults.standard.bool(forKey: OnboardingViewModel.pendingPremiumPromptKey) guard shouldShowPremiumPrompt else { return } + // The paywall was just shown as the final onboarding step — prompting again + // 2s later is prompt fatigue. Defer to the second session instead. + let sessionCountKey = "post_onboarding_session_count" + let sessions = UserDefaults.standard.integer(forKey: sessionCountKey) + 1 + UserDefaults.standard.set(sessions, forKey: sessionCountKey) + guard sessions >= 2 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { showPostOnboardingPremiumPrompt = true } diff --git a/MealMood/Views/Onboarding/OnboardingView.swift b/MealMood/Views/Onboarding/OnboardingView.swift index 4285c73..c7cd76a 100644 --- a/MealMood/Views/Onboarding/OnboardingView.swift +++ b/MealMood/Views/Onboarding/OnboardingView.swift @@ -64,18 +64,26 @@ struct OnboardingView: View { tags: tags, onFinish: { _ = viewModel.completeOnboarding(context: context) + viewModel.autoFillFirstWeek(context: context) viewModel.nextStep() } ) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .tag(4) + WeekReadyStepView( + filledCount: viewModel.autoFilledCount, + onContinue: { viewModel.nextStep() } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .tag(5) + PaywallStepView( onSkip: { onComplete() }, onConverted: { onComplete() } ) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .tag(5) + .tag(6) } .tabViewStyle(.page(indexDisplayMode: .never)) .animation(.easeInOut(duration: 0.3), value: viewModel.currentStep) @@ -126,7 +134,9 @@ struct OnboardingView: View { private var onboardingHeader: some View { VStack(spacing: 6) { HStack { - if viewModel.currentStep > 0 { + // No back navigation once onboarding data is committed (step 4 finish): + // re-finishing would duplicate dishes and the week plan. + if viewModel.currentStep > 0 && viewModel.currentStep < 5 { Button { viewModel.previousStep() } label: { diff --git a/MealMood/Views/Onboarding/WeekReadyStepView.swift b/MealMood/Views/Onboarding/WeekReadyStepView.swift new file mode 100644 index 0000000..0c59887 --- /dev/null +++ b/MealMood/Views/Onboarding/WeekReadyStepView.swift @@ -0,0 +1,115 @@ +import SwiftUI + +/// Onboarding "aha moment" step: shows the freshly auto-filled first week and +/// asks for the weekly reminder with context (instead of a cold OS prompt on Home). +struct WeekReadyStepView: View { + let filledCount: Int + var onContinue: () -> Void + + @State private var isRequestingPermission = false + @State private var reminderGranted = false + + var body: some View { + VStack(spacing: 24) { + VStack(spacing: 16) { + ZStack { + Circle() + .fill(Color.mealMoodCoral.opacity(0.12)) + .frame(width: 88, height: 88) + Image(systemName: filledCount > 0 ? "checkmark.circle.fill" : "calendar.badge.checkmark") + .font(.system(size: 44, weight: .semibold)) + .foregroundColor(.mealMoodCoral) + } + .padding(.top, 24) + + Text("onboarding_week_ready_title") + .font(.mealMoodH1) + .foregroundColor(.mealMoodTextPrimary) + .multilineTextAlignment(.center) + + Text(subtitleText) + .font(.mealMoodBody) + .foregroundColor(.mealMoodTextSecondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 24) + + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + Image(systemName: "bell.badge.fill") + .font(.system(size: 22)) + .foregroundColor(.mealMoodCoral) + Text("onboarding_reminder_pitch") + .font(.mealMoodSmall) + .foregroundColor(.mealMoodTextPrimary) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.mealMoodSurface) + .cornerRadius(14) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(Color(hex: "#F0F0F0"), lineWidth: 1) + ) + .padding(.horizontal, 24) + + Spacer(minLength: 0) + + VStack(spacing: 12) { + PrimaryButton( + title: reminderGranted + ? String(localized: "onboarding_continue") + : String(localized: "onboarding_reminder_allow"), + action: { + if reminderGranted { + onContinue() + } else { + enableReminders() + } + } + ) + .disabled(isRequestingPermission) + + if !reminderGranted { + Button { + onContinue() + } label: { + Text("onboarding_reminder_skip") + .font(.mealMoodSmall) + .foregroundColor(.mealMoodTextSecondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 24) + .padding(.bottom, 40) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + private var subtitleText: String { + if filledCount > 0 { + return String(format: String(localized: "onboarding_week_ready_subtitle"), filledCount) + } + return String(localized: "onboarding_week_ready_subtitle_empty") + } + + private func enableReminders() { + isRequestingPermission = true + Task { + let granted = await NotificationService.shared.requestPermission() + NotificationService.shared.remindersEnabled = true + AnalyticsService.logNotificationsToggled(enabled: granted) + isRequestingPermission = false + if granted { + reminderGranted = true + HapticManager.shared.notification(type: .success) + // Small beat so the user sees the button flip before moving on. + try? await Task.sleep(nanoseconds: 600_000_000) + } + onContinue() + } + } +} diff --git a/MealMood/Views/Settings/SettingsView.swift b/MealMood/Views/Settings/SettingsView.swift index 25cc2f4..0775362 100644 --- a/MealMood/Views/Settings/SettingsView.swift +++ b/MealMood/Views/Settings/SettingsView.swift @@ -1,15 +1,45 @@ import SwiftUI import SwiftData import EventKit +import UserNotifications struct SettingsView: View { @Environment(\.modelContext) private var context @Query private var allSettings: [AppSettings] @StateObject private var viewModel = SettingsViewModel() @State private var showResetAllDataAlert = false + @State private var notificationAuthStatus: UNAuthorizationStatus = .notDetermined + @State private var remindersEnabled = NotificationService.shared.remindersEnabled private var settings: AppSettings? { allSettings.first } + private func refreshNotificationStatus() async { + notificationAuthStatus = await NotificationService.shared.authorizationStatus() + remindersEnabled = NotificationService.shared.remindersEnabled + } + + private func handleReminderToggle(_ enabled: Bool, settings: AppSettings) { + Task { + if enabled { + let granted = await NotificationService.shared.requestPermission() + NotificationService.shared.remindersEnabled = true + remindersEnabled = true + notificationAuthStatus = await NotificationService.shared.authorizationStatus() + AnalyticsService.logNotificationsToggled(enabled: granted) + if granted { + NotificationService.shared.schedulePlanningReminderIfNeeded( + nextWeekPlan: nil, + language: settings.languageEnum.resolved() + ) + } + } else { + NotificationService.shared.remindersEnabled = false + remindersEnabled = false + AnalyticsService.logNotificationsToggled(enabled: false) + } + } + } + var body: some View { ZStack { Color.mealMoodBackground.ignoresSafeArea() @@ -147,6 +177,37 @@ struct SettingsView: View { } .listRowBackground(Color.mealMoodSurface) + // Notifications section + Section { + Toggle("settings_weekly_reminder", isOn: Binding( + get: { remindersEnabled && notificationAuthStatus != .denied }, + set: { newValue in handleReminderToggle(newValue, settings: settings) } + )) + .tint(.mealMoodCoral) + .disabled(notificationAuthStatus == .denied) + + if notificationAuthStatus == .denied { + VStack(alignment: .leading, spacing: 8) { + Text("settings_notifications_denied_hint") + .font(.mealMoodSmall) + .foregroundColor(.mealMoodTextSecondary) + Button { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } label: { + Text("settings_open_system_settings") + .font(.mealMoodSmall) + .foregroundColor(.mealMoodCoral) + } + } + } + } header: { + Label("settings_notifications_header", systemImage: "bell") + } + .listRowBackground(Color.mealMoodSurface) + .task { await refreshNotificationStatus() } + // Tags section Section { NavigationLink(destination: TagListView()) {