1.2.0: contextual notifications + in-onboarding auto-fill (aha moment)

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UtWT52pAE91D7mbDda1cAM
This commit is contained in:
alexandrev-tibco
2026-07-03 10:08:04 +02:00
parent 13ece5f2e7
commit 73c2039338
13 changed files with 415 additions and 49 deletions
+9 -1
View File
@@ -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
}
+12 -2
View File
@@ -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: {
@@ -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()
}
}
}
@@ -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()) {