Home iOS & Swift Books Push Notifications by Tutorials

12
Putting It All Together Written by Scott Grosch

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

You can unlock the rest of this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

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

Open this chapter’s materials and you’ll see a starter project called CoolCalendar prepared with the setup you’ve learned throughout this book. Here’s what the starter project already includes:

  1. The Push Notifications capability as discussed in Chapter 4, “Xcode Project Setup”.
  2. The Remote notifications as part of Background Modes as discussed in Chapter 8, “Handling Common Scenarios”.
  3. An AppDelegate.swift file as discussed in Chapter 4, “Xcode Project Setup”.
  4. An ApnsUploads.swift, NotificationDelegate.swift and TokenDetails.swift files as discussed in Chapter 8, “Handling Common Scenarios”.
  5. A Notification Service Extension, called Payload Modification, as discussed in Chapter 10, “Modifying the Payload”.
  6. A Notification Content Extension, called Custom UI, as discussed in Chapter 11, “Custom Interfaces”.
  7. A Core Data model called Invite representing a calendar invitation.
  8. A NotificationViewController.swift file which includes helpers to display CalendarKit views in a notification.
  9. A Swift package called CalendarKit.

The starter saves you from a bunch of boilerplate so you can hit the ground running.

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!

One potential solution

Start by creating a new file called ActionIdentifier.swift in your main CoolCalendar target, but make sure the Custom UI target is checked as well when you’re creating the file. Add the following enum to the file:

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

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.

import EventKit
@State private var eventStore = EKEventStore()
@State private var askForCalendarPermissions = false
private func onAppear() {
  let status = EKEventStore.authorizationStatus(for: .event)

  switch status {
  case .notDetermined:
    eventStore.requestAccess(to: .event) { granted, _ in
      guard !granted else { return }
      askForCalendarPermissions = true
    }

  case .authorized:
    break

  default:
    askForCalendarPermissions = true
  }
}
private func actionSheet() -> ActionSheet {
  return ActionSheet(
    title: Text("This application requires calendar access"),
    message: Text("Grant access?"),
    buttons: [
      .default(Text("Settings")) {
        let str = UIApplication.openSettingsURLString
        UIApplication.shared.open(URL(string: str)!)
      },
      .cancel()
    ])
}
var body: some View {
  Text("Hello")
    .onAppear(perform: onAppear)
    .actionSheet(isPresented: $askForCalendarPermissions, content: actionSheet)
}

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": "2020-10-20T08:00:00-08:00",
  "end": "2020-10-20T12: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”.

import Foundation

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 }

  if increment == 0 {
    UserDefaults.appGroup.badge = 0
    bestAttemptContent.badge = 0
  } else {
    let current = UserDefaults.appGroup.badge
    let new = current + increment

    UserDefaults.appGroup.badge = new
    bestAttemptContent.badge = NSNumber(value: new)
  }
}
@Environment(\.scenePhase)
private var scenePhase
private func clearBadgeCount(phase: ScenePhase) {
  guard phase == .active else { return }

  UserDefaults.appGroup.badge = 0
  UIApplication.shared.applicationIconBadgeNumber = 0
}
.onChange(of: scenePhase, perform: clearBadgeCount)

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) {
  guard let bestAttemptContent = bestAttemptContent else { return }

  let formatter = ISO8601DateFormatter()
  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)"
}

self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)

guard let bestAttemptContent = bestAttemptContent else {
  return
}

defer { contentHandler(bestAttemptContent) }

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

updateBadge()
updateText(request: request)
{
  "aps": {
    "alert": {
        "title": "New Calendar Invitation"
    },
    "badge": 1,
    "mutable-content": 1,
    "category": "CalendarInvite"
  },
  "title": "Family Reunion",
  "start": "2020-10-20T08:00:00-08:00",
  "end": "2020-10-20T12:00:00-08:00",
  "id": 12
}

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 add the UNNotificationExtensionCategory key with a 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.

view.addSubview(timelineContainer)

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()
var appointments = [
  addCalendarKitEvent(
    start: startDate,
    end: endDate,
    title: title)
]

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 handle 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)

Show your responses

After sending some notifications, it’ll quickly become apparent that nothing is happening in the main app when you accept or reject a notification. You never actually create and display a Core Data entity anywhere. Time to resolve that issue!

Create Core Data entities

Head over to NotificationDelegate.swift one last time. As mentioned, you’ll generate the Core Data entities from one of UNUserNotificationCenterDelegate’s methods. Add an import so you can use Core Data:

import CoreData
private func createInvite(with title: String, starting: Date, ending: Date, accepted: Bool) {
  let context = PersistenceController.shared.container.viewContext
  context.perform {
    let invite = Invite(context: context)
    invite.title = title
    invite.start = starting
    invite.end = ending
    invite.accepted = accepted

    try? context.save()
  }
}
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
}

Define the detail cell view

To prove that everything is working correctly you’ll want to display the invite’s details. You’ll be generating a list row that looks like so:

import SwiftUI

struct AcceptanceImage: View {
  let accepted: Bool

  var body: some View {
    if accepted {
      Image(systemName: "hand.thumbsup")
        .foregroundColor(.green)
    } else {
      Image(systemName: "hand.thumbsdown")
        .foregroundColor(.red)
    }
  }
}

struct AcceptanceImage_Previews: PreviewProvider {
  static var previews: some View {
    Group {
      AcceptanceImage(accepted: true)
        .previewLayout(.fixed(width: 50, height: 50))
      AcceptanceImage(accepted: false)
        .previewLayout(.fixed(width: 50, height: 50))
    }
  }
}
import CoreData
let invite: Invite
static var previews: some View {
  // 1
  let invite = Invite(
    context: PersistenceController.preview.container.viewContext)
  
  // 2
  invite.title = "Event Name"
  invite.accepted = true
  invite.start = Date()
  invite.end = Date().addingTimeInterval(3600)

  // 3
  return InviteCellView(invite: invite)
    .previewLayout(.fixed(width: 300, height: 100))
}
// 1
VStack(alignment: .leading) {
  // 2
  HStack {
    AcceptanceImage(accepted: invite.accepted)
    // 3
    Text(invite.title!)
      .font(.headline)
  }
}
private static let dateFormatter: DateIntervalFormatter = {
  let formatter = DateIntervalFormatter()
  formatter.dateStyle = .short
  formatter.timeStyle = .short
  return formatter
}()
Text(dateFormatter.string(from: invite.start!, to: invite.end!))
  .font(.subheadline)

Update ContentView

Edit ContentView.swift and add the Core Data code which will query all your invitations:

@FetchRequest(
  sortDescriptors: [NSSortDescriptor(keyPath: \Invite.start, ascending: true)],
  animation: .default
)
private var invites: FetchedResults<Invite>
List(invites) { InviteCellView(invite: $0) }

Where to go from here?

@State and bindings might be new concepts if you’re not familiar with SwiftUI. You can take a look at episode 17 in our video tutorial, Your First iOS and SwiftUI App, available at bit.ly/37XJL1b

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