← Back to blog

iOS Notification System Cheat Sheet

Complete Guide to Local Notifications with UserNotifications Framework

Table of Contents

1. The Basics

Request Permission

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")
}

Create a Notification

// 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 Notifications

// 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))")
}

2. Notification Triggers

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

Calendar Trigger - Repeating vs Non-Repeating

❌ 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:

Time Interval Trigger

// 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.

3. Background Execution (The Truth)

❌ What You CAN'T Do

"Run code at 10 PM every night to reschedule notifications"

iOS does NOT allow background processes to run at specific times. Period.

Background Execution Options (None are Perfect)

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

Example: Background App Refresh (Unreliable)

// 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.

4. Notification Delegate Methods

Set the Delegate

// 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()
}

willPresent - Control Foreground Display

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!

didReceive - Handle Notification Tap

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()
}

State Diagram

📱 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

5. Smart Notification Strategies

❌ Strategy 1: Cancel on Data Entry + Reschedule in onAppear (BROKEN)

// 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!

❌ Strategy 2: Suppress in willPresent Only (BROKEN)

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!

✅ Strategy 3: Date-Specific Notifications (WORKS!)

// 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.

6. Common Pitfalls

Pitfall #1: The "Next Occurrence" Trap

// 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! ❌

Pitfall #2: Notification Limit (64)

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)

Pitfall #3: Timezone Changes

// 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 ✅

Pitfall #4: Testing in Simulator

💡 Simulator Quirks:

7. The SteadyMama Solution

Final Architecture

✅ Date-Specific, Non-Repeating Notifications

Schedule:

On Data Entry:

On App Launch:

Implementation Checklist

✅ 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

Edge Cases Handled

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)

Additional Resources