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
+44 -6
View File
@@ -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<Dish>())) ?? []
let tags = (try? context.fetch(FetchDescriptor<Tag>())) ?? []
let weekStart = Date().startOfWeek()
let plans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
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
}
}