Complete Guide to Local Notifications with UserNotifications Framework
import UserNotifications
// Request authorization (usually in Settings or on first launch)
let center = UNUserNotificationCenter.current()
let granted = try await center.requestAuthorization(options: [.alert, .badge, .sound])
if granted {
print("✅ Notifications authorized")
} else {
print("❌ User denied notifications")
}
// 1. Create the content
let content = UNMutableNotificationContent()
content.title = "Glucose Reminder"
content.body = "Time to check your glucose level"
content.sound = .default
content.badge = 1
content.userInfo = ["type": "reading", "meal": "fasting"] // For deep linking
// 2. Create the trigger (when it fires)
var dateComponents = DateComponents()
dateComponents.hour = 7
dateComponents.minute = 0
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
// 3. Create the request
let request = UNNotificationRequest(
identifier: "fasting-reminder", // Unique ID
content: content,
trigger: trigger
)
// 4. Schedule it
try await UNUserNotificationCenter.current().add(request)
// Cancel specific notification
UNUserNotificationCenter.current()
.removePendingNotificationRequests(withIdentifiers: ["fasting-reminder"])
// Cancel ALL notifications
UNUserNotificationCenter.current()
.removeAllPendingNotificationRequests()
// Check what's scheduled
let pending = await UNUserNotificationCenter.current().pendingNotificationRequests()
for request in pending {
print("ID: \(request.identifier), Trigger: \(String(describing: request.trigger))")
}
| Trigger Type | Use Case | Example |
|---|---|---|
UNCalendarNotificationTrigger |
Daily reminders at specific time | 7:00 AM every day |
UNTimeIntervalNotificationTrigger |
Fire after X seconds | 30 seconds from now |
UNLocationNotificationTrigger |
Fire when entering/leaving location | Arrive at hospital |
❌ Problem: Repeating Daily
// Repeats forever at 7 AM
let trigger = UNCalendarNotificationTrigger(
dateMatching: [.hour: 7, .minute: 0],
repeats: true
)
Issues:
✅ Solution: Date-Specific
// Fires once on Oct 29, 2025
let trigger = UNCalendarNotificationTrigger(
dateMatching: [.year: 2025, .month: 10,
.day: 29, .hour: 7, .minute: 0],
repeats: false
)
Benefits:
// Fire in 60 seconds (minimum is 60 seconds, not repeating)
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 60, repeats: false)
// For testing: Fire in 5 seconds (set minimum to 1 in debug builds)
#if DEBUG
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
#endif
⚠️ Important: The minimum time interval for UNTimeIntervalNotificationTrigger is 60 seconds in production. For testing, iOS is more lenient.
"Run code at 10 PM every night to reschedule notifications"
iOS does NOT allow background processes to run at specific times. Period.
| Method | Guaranteed Time? | User Can Disable? | Reliability |
|---|---|---|---|
| Background App Refresh | ❌ No - iOS decides when | ✅ Yes (Settings) | Low |
| BGTaskScheduler | ❌ No - "earliest" only | ✅ Yes (Low Power Mode) | Medium |
| Silent Push Notifications | ⚠️ When server sends | ✅ Yes (needs network) | Medium-High |
| Background Modes (location, audio, etc.) | ❌ Not for timers | N/A | N/A |
// Request background time (iOS decides when to grant it)
import BackgroundTasks
// 1. Register in AppDelegate
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.steadymama.refresh",
using: nil
) { task in
self.handleAppRefresh(task: task as! BGAppRefreshTask)
}
// 2. Schedule when app goes to background
let request = BGAppRefreshTaskRequest(identifier: "com.steadymama.refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 8 * 3600) // 8 hours
try? BGTaskScheduler.shared.submit(request)
// 3. This might run in 8 hours, or 12 hours, or not at all
func handleAppRefresh(task: BGAppRefreshTask) {
// Reschedule notifications here
// But you can't guarantee this runs at 10 PM!
}
💡 Key Insight: The only reliable way to run code is when:
That's it. Everything else is opportunistic and unreliable.
// In your App struct or AppDelegate
import UserNotifications
@main
struct MyApp: App {
init() {
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
}
}
class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationDelegate()
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
// 🔍 This ONLY fires when app is IN FOREGROUND
// Check if we should show this notification
let userInfo = notification.request.content.userInfo
if shouldSuppressNotification(userInfo: userInfo) {
// Don't show it
completionHandler([])
} else {
// Show banner and play sound
completionHandler([.banner, .sound])
}
}
⚠️ CRITICAL: willPresent only runs when app is in the foreground!
This is why "check Core Data in willPresent" doesn't work for background notifications!
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
// User tapped the notification
let userInfo = response.notification.request.content.userInfo
// Handle deep linking
if let type = userInfo["type"] as? String,
let meal = userInfo["meal"] as? String {
// Navigate to appropriate screen
DeepLinkCoordinator.shared.handleNotification(type: type, meal: meal)
}
completionHandler()
}
📱 Notification Fires at 7:00 AM
│
├─ App is FOREGROUND
│ └─> willPresent() called
│ └─> Can suppress here ✅
│
├─ App is BACKGROUND
│ └─> Notification shows
│ └─> NO METHOD CALLED ❌
│
└─ App is TERMINATED
└─> Notification shows
└─> NO METHOD CALLED ❌
User taps notification
└─> didReceive() called
└─> Handle deep link
// TodayView
func addReading() {
saveToDatabase()
NotificationManager.shared.cancelGlucoseReminder(for: "fasting") // Cancel it
}
// TodayView.onAppear()
func onAppear() {
NotificationManager.shared.scheduleAllNotifications() // Reschedule everything!
}
// 🔴 PROBLEM:
// - User enters data at 7 AM → Cancels fasting reminder ✅
// - User switches tabs → onAppear() reschedules it ❌
// - 9 AM: Fasting reminder fires even though data exists!
func willPresent(notification) {
if dataExistsInCoreData() {
completionHandler([]) // Suppress it
} else {
completionHandler([.banner, .sound])
}
}
// 🔴 PROBLEM:
// - Only works when app is in FOREGROUND
// - User closes app → willPresent() never called
// - Notification shows anyway!
// Schedule 7 days ahead, each with unique identifier
func scheduleNotifications() {
let calendar = Calendar.current
for dayOffset in 0...6 { // Schedule 7 days
guard let date = calendar.date(byAdding: .day, value: dayOffset, to: Date()) else { continue }
let components = calendar.dateComponents([.year, .month, .day], from: date)
var dateComponents = components
dateComponents.hour = 7 // 7 AM
dateComponents.minute = 0
let trigger = UNCalendarNotificationTrigger(
dateMatching: dateComponents,
repeats: false // One-time only!
)
let identifier = "fasting-\(components.year!)-\(components.month!)-\(components.day!)"
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: trigger
)
UNUserNotificationCenter.current().add(request)
}
}
// When user enters data
func addReading() {
saveToDatabase()
// Cancel ONLY today's notification
let today = Calendar.current.dateComponents([.year, .month, .day], from: Date())
let identifier = "fasting-\(today.year!)-\(today.month!)-\(today.day!)"
UNUserNotificationCenter.current()
.removePendingNotificationRequests(withIdentifiers: [identifier])
// Tomorrow's notification is unaffected ✅
}
// When app opens, refresh the schedule
func refreshNotifications() {
// Check what's currently scheduled
let pending = await UNUserNotificationCenter.current().pendingNotificationRequests()
// If we have fewer than 3 days scheduled, schedule more
if pending.count < 12 { // 4 reminders × 3 days
scheduleNotifications()
}
}
✅ Why This Works:
⚠️ Trade-off: If user doesn't open app for 7+ days, notifications stop (ran out of scheduled days). This is acceptable for a health tracking app where daily use is expected.
// User enters lunch data at 7 AM
// Lunch notification is scheduled for 12 PM
// If you cancel and reschedule with repeating trigger:
let trigger = UNCalendarNotificationTrigger(
dateMatching: [.hour: 12, .minute: 0],
repeats: true
)
// iOS schedules for next occurrence of 12 PM
// Current time: 7 AM
// Next 12 PM: TODAY at 12 PM (not tomorrow!)
// Notification still fires at noon even though data exists! ❌
iOS limits you to 64 pending notifications total.
// If you schedule 30 days × 4 reminders = 120 notifications
// iOS will only keep the first 64
// Solution: Schedule 7-14 days ahead
// 7 days × 4 reminders = 28 notifications (well under limit)
// User schedules notification in NYC (EST)
// Travels to LA (PST)
// Notification uses calendar components, so it fires at local time ✅
// BUT: If you use Date instead of DateComponents, it fires at absolute time
let wrongTrigger = UNTimeIntervalNotificationTrigger(
timeInterval: targetDate.timeIntervalSinceNow,
repeats: false
)
// This won't adjust to timezone! ❌
// Always use UNCalendarNotificationTrigger for daily reminders ✅
💡 Simulator Quirks:
Schedule:
"{type}-{year}-{month}-{day}""fasting-2025-10-29"On Data Entry:
On App Launch:
✅ NotificationManager changes:
- scheduleGlucoseReminders(daysAhead: Int = 7)
- scheduleForSpecificDate(date: Date, type: String, meal: String)
- cancelNotificationForToday(type: String, meal: String)
- refreshNotifications() - called on app launch
✅ TodayView changes:
- Call cancelNotificationForToday() after saving data
- Remove all other notification scheduling logic
✅ SteadyMamaApp changes:
- Call refreshNotifications() in init or onAppear
✅ Remove:
- shouldSuppressNotification() - no longer needed
- willPresent suppression logic - no longer needed
- Cancel/reschedule in view lifecycle
| Scenario | Behavior | Status |
|---|---|---|
| Enter data before notification time | Notification cancelled, won't fire | ✅ |
| Enter all day's data in morning | All today's notifications cancelled | ✅ |
| App in background | Cancelled notifications don't fire | ✅ |
| Delete data after entry | Notification already cancelled (won't re-fire today) | ⚠️ Expected |
| Don't open app for 7+ days | Notifications stop (ran out of scheduled days) | ⚠️ Acceptable |
| Timezone change | Fires at local time (uses DateComponents) | ✅ |