Home iOS & Swift Books Push Notifications by Tutorials

12
Putting It All Together Written by Scott Grosch

With 11 chapters behind you, you’ve become quite the master of everything related to Push Notifications!

This chapter is all about leveraging all that you’ve learned in this book into a single app, titled CoolCalendar.

When somebody sends you a calendar invite, it will be pushed to your device via a remote notification. You’ll have the ability to see how the new event relates to your existing calendars, be able to accept/reject right from the notification and have the option of sending a comment back.

Setting up the Xcode project

The starter project for this chapter uses a third-party library called CalendarKit, which comes pre-installed via CocoaPods, to simplify the presentation of a Calendar UI in your app. Be sure to open CoolCalendar.xcworkspace and not CoolCalendar.xcodeproj. If you accidentally open the latter, you’ll get multiple compiler errors.

First, it’s time to set up your workspace:

  1. Open CoolCalendar.xcworkspace from the starter project.

  2. Set the team signing for both the CoolCalendar target and the Custom UI extension, as discussed in Chapter 7, “Expanding the Application.”

  3. Enable the Push Notifications capability as discussed in Chapter 4, “Xcode Project Setup.”

  4. Enable Remote notifications as part of Background Modes as discussed in Chapter 8, “Handling Common Scenarios.”

  5. Add a Notification Service Extension as discussed in Chapter 10, “Modifying the Payload.”

A Notification Content Extension was already included in the starter project, as some sample code was provided for you. In your own projects, you’d have to create that target yourself.

AppDelegate code

Take a minute to set up your AppDelegate code the way you think it should be. Keep in mind all the items discussed in the preceding chapters and don’t be afraid to flip back to one or more for help!

enum ActionIdentifier: String {
  case accept
  case decline
  case comment
}
private let categoryIdentifier = "CalendarInvite"

private func registerCustomActions() {
  let accept = UNNotificationAction(
    identifier: ActionIdentifier.accept.rawValue,
    title: "Accept")

  let decline = UNNotificationAction(
    identifier: ActionIdentifier.decline.rawValue,
    title: "Decline")

  let comment = UNTextInputNotificationAction(
    identifier: ActionIdentifier.comment.rawValue,
    title: "Comment", options: [])

  let category = UNNotificationCategory(
    identifier: categoryIdentifier,
    actions: [accept, decline, comment],
    intentIdentifiers: [])

  UNUserNotificationCenter
    .current()
    .setNotificationCategories([category])
}
func application(_ application: UIApplication,
                didRegisterForRemoteNotificationsWithDeviceToken 
                deviceToken: Data) {
  registerCustomActions()
  sendPushNotificationDetails(
    to: "http://192.168.1.1:8080/api/token",
    using: deviceToken)
}
extension AppDelegate: UNUserNotificationCenterDelegate {
  func userNotificationCenter(_ center: UNUserNotificationCenter, 
    willPresent notification: UNNotification, 
    withCompletionHandler completionHandler: 
    @escaping (UNNotificationPresentationOptions) -> Void) {
    completionHandler([.badge, .sound, .alert])
  }
}
registerForPushNotifications(application: application)

Requesting calendar permissions

Accessing the user’s calendar is a privacy concern, and so you’ll have to first request permission of your end users. Apple kindly ensured that the same authorization status is shared by all targets of your app.

private let eventStore = EKEventStore()
override func viewWillAppear(_ animated: Bool) {
  super.viewWillAppear(animated)

  let status = EKEventStore.authorizationStatus(for: .event)

  switch (status) {
  case .notDetermined:
    eventStore.requestAccess(to: .event) { 
      [weak self] granted, _ in
      guard !granted, let self = self else { return }

      self.askForAccess()
    }

  case .authorized:
    break

  default:
    askForAccess()
  }
}
private func askForAccess() {
  let alert = UIAlertController(
    title: nil,
    message: "This application requires calendar access",
    preferredStyle: .actionSheet)

  alert.addAction(UIAlertAction(
    title: "Settings", 
    style: .default) { _ in
      let url = URL(
        string: UIApplication.openSettingsURLString)!
      UIApplication.shared.open(url)
  })

  alert.addAction(UIAlertAction(
    title: "Cancel", 
    style: .default))

  present(alert, animated: true)
}

The payload

When it’s time to invite somebody to an event, you’ll send a remote notification with a payload that looks like this:

{
  "aps": {
    "alert": {
        "title": "New Calendar Invitation"
    },
    "badge": 1,
    "mutable-content": 1,
    "category": "CalendarInvite"
  },
  "title": "Family Reunion",
  "start": "2018-04-10T08:00:00-08:00",
  "end": "2018-04-10T12:00:00-08:00",
  "id": 12
}

Notification Service Extension

The goal of your remote push notification is to provide a custom user interface to accept/reject/comment on the calendar invitation that’s sent to the end user. What happens if the end user doesn’t allow calendar access? It would be pretty strange to pop up the UI asking them to take action.

Validating calendar permissions

Modify NotificationService.swift to include an import of EventKit:

import EventKit
if EKEventStore.authorizationStatus(for: .event) != .authorized {
  bestAttemptContent.categoryIdentifier = ""
}

App badging

It’s time to add a badge to your app’s icon for when a new notification comes in. The first step is to create an App Group. If you don’t remember how to do this, follow the steps shown in Chapter 10, “Modifying the Payload.”

extension UserDefaults {
  static let appGroup = UserDefaults(
    suiteName: "group.com.raywenderlich.CoolCalendar")!

  private enum Keys {
    static let badge = "badge"
  }

  var badge: Int {
    get {
      return integer(forKey: Keys.badge)
    } set {
      set(newValue, forKey: Keys.badge)
    }
  }
}
private func updateBadge() {
  guard let bestAttemptContent = bestAttemptContent,
        let increment = bestAttemptContent.badge as? Int else { return }
  
  switch increment {
  case 0:
    UserDefaults.appGroup.badge = 0
    bestAttemptContent.badge = 0
  default:
    let current = UserDefaults.appGroup.badge
    let new = current + increment
    
    UserDefaults.appGroup.badge = new
    bestAttemptContent.badge = NSNumber(integerLiteral: new)
  }
}
func applicationDidBecomeActive(_ application: UIApplication) {
  UserDefaults.appGroup.badge = 0
  application.applicationIconBadgeNumber = 0
}

Notification body

The final task is parsing your custom payload data and updating the body of the notification message to something more user friendly.

private func updateText(request: UNNotificationRequest) {
  let formatter = ISO8601DateFormatter()
  
  guard let bestAttemptContent = bestAttemptContent else { return }

  let authStatus = EKEventStore.authorizationStatus(for: .event)
  
  guard authStatus == .authorized,
        let userInfo = request.content.userInfo as? [String: Any],
        let title = userInfo["title"] as? String,
        !title.isEmpty,
        let start = userInfo["start"] as? String,
        let startDate = formatter.date(from: start),
        let end = userInfo["end"] as? String,
        let endDate = formatter.date(from: end),
        userInfo["id"] as? Int != nil else {
    bestAttemptContent.categoryIdentifier = ""
    return
  }
  
  let rangeFormatter = DateIntervalFormatter()
  rangeFormatter.dateStyle = .short
  rangeFormatter.timeStyle = .short
  
  let range = rangeFormatter.string(from: startDate,
                                    to: endDate)
  bestAttemptContent.body = "\(title)\n\(range)"
}
override func didReceive(_ request: UNNotificationRequest, 
                         withContentHandler contentHandler: 
                         @escaping (UNNotificationContent) -> Void) {
  self.contentHandler = contentHandler
  bestAttemptContent = request.content.mutableCopy() 
    as? UNMutableNotificationContent
  guard 
    let bestAttemptContent = bestAttemptContent 
  else { return }

  if EKEventStore.authorizationStatus(for: .event) 
     != .authorized {
    bestAttemptContent.categoryIdentifier = ""
  }

  updateBadge()
  updateText(request: request)
  contentHandler(bestAttemptContent)
}

Content Service Extension

Instead of just asking for a response, wouldn’t it be nicer to show your users what their calendars look like for the time period related to the new event that they were invited to? To do this, you’ll use a library called CalendarKit that the starter project has included. As the goal here isn’t to teach you how to use CalendarKit, the starter project already includes the code related to that library for you.

Updating the Info.plist

Open up Info.plist inside of the Custom UI target folder and expand out the NSExtension key all the way, as you learned to do in Chapter 11, “Custom Interfaces.” You’ll need to update the UNNotificationExtensionCategory value to match what you set the categoryIdentifier to be in AppDelegate.swift. If you’ve used the same category name as the book example, that means you’ll need to put CalendarInvite as the value.

Adding information to CalendarKit

You’ll have to do the same extraction from the payload that you did in the Notification Service Extension, but, this time, there’s no need to check for calendar access because, if you get here, it’s guaranteed to be “on” as you just checked it in the Notification Service Extension.

let formatter = ISO8601DateFormatter()

guard let userInfo = notification.request.content.userInfo as? [String: Any],
      let title = userInfo["title"] as? String, !title.isEmpty,
      let start = userInfo["start"] as? String,
      let startDate = formatter.date(from: start),
      let end = userInfo["end"] as? String,
      let endDate = formatter.date(from: end),
      let id = userInfo["id"] as? Int else {
  return
}

var appointments = [addCalendarKitEvent(start: startDate, 
                                        end: endDate, 
                                        title: title)]

Getting nearby calendar items

Now that the new invitation is squared away, you’ll need to find the events in the users’ existing calendars that will occur around the same date/time. It’s probably a good idea to consider a couple of hours before and after the event so that your users can plan for drive times, doctors always being late to the start of an appointment or other buffers of time needed.

private let eventStore = EKEventStore()
let calendar = Calendar.current
let displayStart = calendar.date(byAdding: .hour, 
                                 value: -2,
                                 to: startDate)!
let displayEnd = calendar.date(byAdding: .hour, 
                               value: 2,
                               to: endDate)!

let predicate = eventStore.predicateForEvents(
  withStart: displayStart,
  end: displayEnd,
  calendars: nil)

appointments += eventStore
  .events(matching: predicate)
  .map {
    addCalendarKitEvent(
      start: $0.startDate, 
      end: $0.endDate,
      title: $0.title, 
      cgColor: $0.calendar.cgColor)
  }

Accepting and declining

The UI is now displaying a snapshot of part of the calendar so that the end user can make an informed decision about whether or not to accept the invitation. What happens when they accept or reject, though? You’ll want to determine which option was chosen and then take some action, such as connecting to a REST endpoint to store the response.

private var calendarIdentifier: Int?
calendarIdentifier = id
func didReceive(
  _ response: UNNotificationResponse, 
  completionHandler completion: 
  @escaping (UNNotificationContentExtensionResponseOption) 
  -> Void) {
  
  guard let choice = 
    ActionIdentifier(rawValue: response.actionIdentifier) 
  else {
    // This shouldn't happen but definitely don't crash.  
    // Let the users report a bug that nothing happens
    // for this choice so you can fix it.
    completion(.doNotDismiss)
    return  
  }

  switch choice {
  case .accept, .decline:
    completion(.dismissAndForwardAction)
  case .comment:
    completion(.doNotDismiss)
  }
}

Commenting on the invitation

This one takes a little more work to get properly. You want to be able to comment without the notification being dismissed as soon as you do. In order to make that happen, you’ve got to tell iOS that you’re willing to become the first responder (i.e., provide a custom keyboard) and handle input by overriding canBecomeFirstResponder.

override var canBecomeFirstResponder: Bool {
  return true
}
guard textField == keyboardTextField,
      let text = textField.text,
      let calendarIdentifier = calendarIdentifier else {
  return true
}

Server.shared.commentOnInvitation(with: calendarIdentifier,
                                  comment: text)
textField.text = nil

keyboardTextField.resignFirstResponder()
resignFirstResponder()
override var inputAccessoryView: UIView? {
  return keyboardInputAccessoryView
}
case .comment:
  becomeFirstResponder()
  keyboardTextField.becomeFirstResponder()
  completion(.doNotDismiss)

Final cleanups

After sending some notifications, it’ll quickly become apparent that nothing is happening in the main app when you accept or reject a notification. There is already code in ViewController.swift that shows the data, but you never actually create a Core Data entity anywhere. Time to resolve that issue!

func userNotificationCenter(
  _ center: UNUserNotificationCenter,
  didReceive response: UNNotificationResponse,
  withCompletionHandler completionHandler: @escaping () 
  -> Void) {
  
  defer { completionHandler() }
  
  let formatter = ISO8601DateFormatter()
  let content = response.notification.request.content
  
  guard 
    let choice = 
    ActionIdentifier(rawValue: response.actionIdentifier),
    let userInfo = content.userInfo as? [String : Any],
    let title = userInfo["title"] as? String, !title.isEmpty,
    let start = userInfo["start"] as? String,
    let startDate = formatter.date(from: start),
    let end = userInfo["end"] as? String,
    let endDate = formatter.date(from: end),
    let calendarIdentifier = userInfo["id"] as? Int else {
      return
  }
}
switch choice {
case .accept:
  Server.shared.acceptInvitation(with: calendarIdentifier)
  createInvite(
    with: title,
    starting: startDate,
    ending: endDate,
    accepted: true)
  
case .decline:
  Server.shared.declineInvitation(with: calendarIdentifier)
  createInvite(
    with: title,
    starting: startDate,
    ending: endDate,
    accepted: false)
  
default:
  break
}

Where to go from here?

And, with that, your project is finished! Even so, there is still one final category of notifications to cover: local notifications, which you will explore in the next and final chapter.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2021 Razeware LLC

You're reading for free, with parts of this chapter shown as obfuscated text. Unlock this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.