73c2039338
- 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
121 lines
4.8 KiB
Swift
121 lines
4.8 KiB
Swift
import Foundation
|
|
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()
|
|
guard settings.authorizationStatus == .notDetermined else { return }
|
|
_ = 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"
|
|
|
|
let isComplete = nextWeekPlan?.slots.allSatisfy { $0.dishId != nil || $0.isEatingOut } ?? false
|
|
if isComplete {
|
|
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
|
} else {
|
|
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)
|
|
}
|
|
|
|
scheduleSundayReminderIfNeeded(nextWeekPlan: nextWeekPlan, language: language)
|
|
}
|
|
|
|
private func scheduleSundayReminderIfNeeded(nextWeekPlan: WeekPlan?, language: AppLanguage) {
|
|
let center = UNUserNotificationCenter.current()
|
|
let identifier = "mealmood.sunday-planning"
|
|
|
|
// Cancel if next week is already planned
|
|
let hasAnySlot = nextWeekPlan?.slots.contains { $0.dishId != nil || $0.isEatingOut } ?? false
|
|
if hasAnySlot {
|
|
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
|
return
|
|
}
|
|
|
|
// Schedule for next Sunday at 17:00
|
|
var components = DateComponents()
|
|
components.weekday = 1 // Sunday
|
|
components.hour = 17
|
|
components.minute = 0
|
|
|
|
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
|
|
let content = UNMutableNotificationContent()
|
|
content.title = localizedString("notification_sunday_title", language: language)
|
|
content.body = localizedString("notification_sunday_body", language: language)
|
|
content.sound = .default
|
|
|
|
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
|
|
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
|
center.add(request)
|
|
}
|
|
}
|
|
|