How to schedule local notifications for weekdays and timestamps


Greetings, traveler!

Notifications are a popular tool for communicating with users. They can be local or remote. Remote notifications can be handled via external services like Firebase or Supabase, while local ones can be created and scheduled inside the app. Let’s take a closer look at how we can do this.

Notification Service

Imagine we have a Habit tracker. So there we have a model of a habit. This model has its ID, title, weekday array, and timestamps. So, users can schedule notifications on specific days at chosen timestamps.

struct Habit {
    var id: UUID = .init()
    var title: String = "Do 10 pull-ups"
    var weekDays: [Locale.Weekday] = [.monday, .wednesday, .friday]
    var timestamps: [Date] = [.now]
}

Now, we can create a Notification Service to schedule notifications for concrete habits. First, import the UserNotifications framework. Then, make a notification scheduling function. There are several steps that we should achieve within this function scope.

  1. Create a UNMutableNotificationContent instance and set its title and subtitle.
  2. Create an array of notification IDs to fill. We will use it to gain the ability to delete scheduled notifications.
  3. Schedule repeatable notifications for every chosen weekday at specific timestamps.

We will use UNCalendarNotificationTrigger to trigger notifications. This class awaits date components to call the notification system at a specific time. We can compose these components using our timestamps array to specify hours and minutes and the weekday array to specify the weekdays. Since DateComponents day is an optional Int, we can create a dictionary, which will help us retrieve an offset value for a specific weekday.

This dictionary will be created by the array of all weekdays. To generate this array, we can map all Calendar weekdaySymbols into an array of Locale.Weekday. To make our code a bit cleaner, let’s create an extension. Note that Locale.Weekday rawValue only has the first three letters of the day of the week name, so we have to use the prefix here. And since the offset starts from zero, we can add “1” to its value.

extension Locale.Weekday {
    static var allCases: [Locale.Weekday] {
        Calendar
            .current
            .weekdaySymbols
            .compactMap { Locale.Weekday(rawValue: String($0.lowercased().prefix(3))) }
    }
    
    static var dayOfTheWeekDictionary: [Locale.Weekday: Int] {
        Dictionary(uniqueKeysWithValues: allCases.enumerated().map { ($0.element, $0.offset + 1) })
    }
}

// Example of Usage

let dictionary = Locale.Weekday.dayOfTheWeekDictionary // [.sunday: 1, .monday: 2, .tuesday: 3, .wednesday: 4, .thursday: 5, .friday: 6, .saturday: 7]
dictionary[.saturday] // 7

Now, we can use it with our NoticiationService.

struct NotificationService {
        
    private func scheduleNotifications(for item: Habit) async throws -> [String] {
        let content = UNMutableNotificationContent()
        content.title = "My App notification"
        content.subtitle = item.title
        
        var notificationIDs: [String] = []
        let calendar = Calendar.current
        let weekdayOffsetDict = Locale.Weekday.dayOfTheWeekDictionary
        
        for weekday in item.weekDays {
            for date in item.timestamps {
                let id = UUID().uuidString
                let hour = calendar.component(.hour, from: date)
                let min = calendar.component(.minute, from: date)
                guard let day = weekdayOffsetDict[weekday] else { continue }
                
                var components = DateComponents()
                components.hour = hour
                components.minute = min
                components.weekday = day
                
                let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
                let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)
                
                notificationIDs.append(id)
                
                try await UNUserNotificationCenter.current().add(request)
            }
        }
        
        return notificationIDs
    }
    
}

Save and Delete

We should give users the ability to delete scheduled notifications. First, we should save their IDs somewhere. In this article, we will use UserDafults.

private enum Constants {
    static let notificationsKey = "notificationsKey"
}

private func saveValueInUserDefaults(_ value: [String]?, id: String) {
    var notificationDict = (UserDefaults.standard.object(forKey: Constants.notificationsKey) as? [String: [String]]) ?? [:]
    notificationDict[id] = value
    
    UserDefaults.standard.setValue(notificationDict, forKey: Constants.notificationsKey)
}

Now, we can retrieve them via the habit model ID.

private func getNotificationIDsFromUserDefaults(for id: String) -> [String] {
    let notificationDict = (UserDefaults.standard.object(forKey: Constants.notificationsKey) as? [String: [String]]) ?? [:]
    
    return notificationDict[id] ?? []
}

After that, we can easily delete them.

func deleteNotifications(for item: Habit) {
    let ids = getNotificationIDsFromUserDefaults(for: item.id.uuidString)
    UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids)
    saveValueInUserDefaults(nil, id: item.id.uuidString)
}

Create notifications

The last step is creating notifications using tools we made earlier.

extension NotificationService {
    
    func createNotifications(for item: Habit) async {        
        do {
            let ids = try await scheduleNotifications(for: item)
            saveValueInUserDefaults(ids, id: item.id.uuidString)
        } catch {
            print(error)
        }
    }
    
}

Notifications limit

Local notifications are limited if you are using UILocalNotification. Apple mentioned this detail in its documentation.

An app can have only a limited number of scheduled notifications; the system keeps the soonest-firing 64 notifications (with automatically rescheduled notifications counting as a single notification) and discards the rest.

We are using UNNotificationRequest, and there is nothing about it in its documentation. However, this rule still exists, so you should keep that in mind. To make it more obvious, we can create a function to check the current notification count.

func createNotifications(for item: Habit) async {
    guard let total = try? await notificationsCount() else {
        return
    }
    
    if total >= 64 {
        print(total)
        return
    }
    
    do {
        let ids = try await scheduleNotifications(for: item)
        saveValueInUserDefaults(ids, id: item.id.uuidString)
    } catch {
        print(error)
    }
}

private func notificationsCount() async throws -> Int {
    let notificationCenter = UNUserNotificationCenter.current()
    let notificationRequests = await notificationCenter.pendingNotificationRequests()
    
    return notificationRequests.count
}

Thats it! And here is the final result.

import UserNotifications

struct Habit {
    let id: UUID
    let title: String
    let weekDays: [String]
    let timestamps: [Date]
}

struct NotificationService {

    private enum Constants {
        static let notificationsKey = "notificationsKey"
    }
        
    private func scheduleNotifications(for item: Habit) async throws -> [String] {
        let content = UNMutableNotificationContent()
        content.title = "My App notification"
        content.subtitle = item.title
        
        var notificationIDs: [String] = []
        let calendar = Calendar.current
        let weekdayOffsetDict = Locale.Weekday.dayOfTheWeekDictionary
        
        for weekday in item.weekDays {
            for date in item.timestamps {
                let id = UUID().uuidString
                let hour = calendar.component(.hour, from: date)
                let min = calendar.component(.minute, from: date)
                guard let day = weekdayOffsetDict[weekday] else { continue }
                
                var components = DateComponents()
                components.hour = hour
                components.minute = min
                components.weekday = day
                
                let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
                let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)
                
                notificationIDs.append(id)
                
                try await UNUserNotificationCenter.current().add(request)
            }
        }
        
        return notificationIDs
    }
    
    private func saveValueInUserDefaults(_ value: [String]?, id: String) {
        var notificationDict = (UserDefaults.standard.object(forKey: Constants.notificationsKey) as? [String: [String]]) ?? [:]
        notificationDict[id] = value
        
        UserDefaults.standard.setValue(notificationDict, forKey: Constants.notificationsKey)
    }
    
    private func getNotificationIDsFromUserDefaults(for id: String) -> [String] {
        let notificationDict = (UserDefaults.standard.object(forKey: Constants.notificationsKey) as? [String: [String]]) ?? [:]
        
        return notificationDict[id] ?? []
    }
    
    private func notificationsCount() async throws -> Int {
        let notificationCenter = UNUserNotificationCenter.current()
        let notificationRequests = await notificationCenter.pendingNotificationRequests()
        
        return notificationRequests.count
    }
    
}

extension NotificationService {
    
    func createNotifications(for item: Habit) async {
        guard let total = try? await notificationsCount() else {
            return
        }
        
        if total >= 64 {
            print(total)
            return
        }
        
        do {
            let ids = try await scheduleNotifications(for: item)
            saveValueInUserDefaults(ids, id: item.id.uuidString)
        } catch {
            print(error)
        }
    }
    
    func deleteNotifications(for item: Habit) {
        let ids = getNotificationIDsFromUserDefaults(for: item.id.uuidString)
        UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids)
        saveValueInUserDefaults(nil, id: item.id.uuidString)
    }
    
}

Conclusion

Local notifications can become a helpful tool that doesn’t rely on the network connection but has some imitations.