Chapters

Hide chapters

Push Notifications by Tutorials

Fourth Edition · iOS 16 · Swift 5 · Xcode 14

Section I: Push Notifications by Tutorials

Section 1: 15 chapters
Show chapters Hide chapters

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

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. NotificationCenter.swift, PushNotifications.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 Challenge

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!

Some features that you’ll want to be sure to handle:

  • You’ll need action identifiers to know which custom action buttons were selected. Plan to have buttons for Accept, Decline and Comment.
  • You’ll need to register all of your custom actions.
  • You’ll need to register for push notifications.
  • The payload’s custom category will be called CalendarInvite.

Try to do this yourself. When you’re ready, read the section below to find one potential solution. Don’t peek until you’ve tried it yourself!

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
}

This defines your Action Identifiers.

Next, add your custom actions to AppDelegate.swift by adding the following code inside the class:

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

Then call that method from the end of application(_:didRegisterForRemoteNotificationsWithDeviceToken:)

registerCustomActions()

If you build your app, it should compile cleanly at this point. Be sure not to move on until you are left with no compiler errors.

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.

This means that your extensions can simply look at the status and not have to ask for it, as the primary target already takes care of that. Like all good iOS apps, you’ll have to tell your end users why you want to get into their calendars, so go back to the CoolCalendar target’s Info panel and add a Privacy — Calendars Usage Description key.

You can use any text that explains why you need access to the Calendar, such as, “We need access to the Calendar to import your events and important dates.”

Now, edit ContentView and request permission to the user’s calendar when the view appears. This is boilerplate code that you’ll use in any calendar app, but it’s important to get it right.

First, you’ll need to tell Xcode you’re going to work with events by adding an import statement to the top of the file:

import EventKit

The event store is the way your app will communicate with the calendar data so add a property for it to the top of the struct:

@State private var eventStore = EKEventStore()

You’ll need to track whether or not an alert should appear asking for permissions so add some state for that just above where you set the eventStore:

@State private var askForCalendarPermissions = false

Whenever the view appears you need to ensure that calendar permissions are still granted. If they’re not, you should flag that you need to ask for those permissions. Add an onAppear method to the ContentView struct:

func onAppear() {
  let status = EKEventStore.authorizationStatus(for: .event)
  switch status {
  case .notDetermined:
    Task {
      askForCalendarPermissions = try! await eventStore.requestAccess(to: .event)
    }
  case .authorized:
    break
  default:
    askForCalendarPermissions = true
  }
}

If access has to be requested, then you’ll want to display an action sheet requesting the user grant you those rights. Right after onAppear, add a method to configure the ActionSheet which will appear.

func actionSheet() -> ActionSheet {
  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()
    ])
}

Finally, wire those two methods up to the body for your view. Your body should look like this now:

var body: some View {
  Text("Hello")
    .onAppear(perform: onAppear)
    .actionSheet(isPresented: $askForCalendarPermissions, content: actionSheet)
}

While you could have placed those two methods inline with the body, separating the functionality into methods keeps the code cleaner and easier to test.

If you’re not familiar with SwiftUI, that all probably looks like magic. Because askForCalendarPermissions was marked as @State, the view knows that it controls that variable and it can bind to it. The $ in front of the variable name tells the action sheet that it should be presented when the value is true. Because it’s a binding, the check is constantly performed. Thus, as soon as you set the value to true, the action sheet will appear.

Note: It’s a nice touch to give the user a simple way to get to your app settings, if permissions aren’t currently granted.

However, you should be sure where you’re doing this from. In the case of this app, it’s done every time the view appears if permission has been denied or not yet requested.

If your app requires calendar access, this makes sense. However, if it’s optional, and not 100% necessary to your app’s functionality, then you’ll just annoy the end user if you ask every single time.

Build and run the app to verify that you’re asked to grant calendar permissions and to allow push notifications.

Be sure to tap OK on both! If the app crashes at this point, you probably added the privacy policy to one of the extension targets instead of the main app.

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": "2022-08-20T08:00:00-08:00",
  "end": "2022-08-20T12:00:00-08:00",
  "id": 12
}

Notice that you’re setting the mutable-content key to 1 so that your service extension runs, as well as a category so that your custom UI extension is triggered. The last four fields simply specify the details of the event.

This packet structure also assumes that your server is tracking the calendar invitations to know who accepted and rejected them, which is why there is an id key which uniquely identifies this invitation in your database. To make life easy, the dates use the ISO8601 date format.

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.

Even though you can’t stop a notification from going through, you can change the notification. In this case, that means that you should check if calendar permissions are granted.

What action do you think you could take if permission is denied? The simplest solution is to remove the category from the payload, which would prevent the custom UI from appearing at all!

The pieces to be implemented in the service extension are threefold:

  1. Remove the category if calendar permissions aren’t granted.
  2. Update the app icon badge.
  3. Update the body of the notification.

Spend some time trying to implement those three items yourself and then come back to see the way the goals are accomplished, here.

Validating Calendar Permissions

Modify NotificationService.swift, in the Payload Modification target, to include an import of EventKit:

import EventKit

Then update didReceive(_:withContentHandler:) to blank out the category field of the payload if calendar permissions are denied by adding this check to the bottom of the method:

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

Setting categoryIdentifier to an empty string will ensure the content UI doesn’t display.

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

Badging the app is handled by using the App Group you created and the UserDefaults class. Create a new Swift file called UserDefaults.swift in the CoolCalendar target with code to set an integer in the proper App Group. Be sure you update the name of the suite to match what you called the App Group!

import Foundation

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

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

  var badge: Int {
    get {
      return integer(forKey: Keys.badge)
    } set {
      set(newValue, forKey: Keys.badge)
    }
  }
}

Since you’re going to use this file in both the primary target and the service extension, click on the newly created file and, in the File Inspector, check Payload Modification inside Target membership.

With that done, you can now add a method to NotificationService.swift to update the badge:

private func updateBadge() {
  guard
    let 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)
  }
}

When the service extension completes, iOS will set the badge on your app icon to the value specified in the payload. You’ve handled the update here by incrementing the existing badge count based on what was sent in the payload so that the count properly increments after each invitation is received.

When the user runs the app, you’ll of course want to blank the badge count out. Edit CoolCalendarApp.swift and grab the scene phase from the environment by adding the following to the struct:

@Environment(\.scenePhase)
private var scenePhase

When the scene moves to an active state you’ll want to blank out the badge count. Add the following method:

private func clearBadgeCount(phase: ScenePhase) {
  guard phase == .active else { return }

  UserDefaults.appGroup.badge = 0
  UIApplication.shared.applicationIconBadgeNumber = 0
}

If the phase isn’t moving to an active state there’s nothing to do. However, when the app becomes active, you’ll clear out the badge.

You’ll notice that the UserDefaults extension wasn’t technically necessary here as you’re not decrementing based on which invitations you’ve seen. In a normal production app, however, you’d only want to decrement the count when the user actually sees the specific invitations that were new.

All that’s left to do is call that method. Add the following line to the body, just after putting the managed object context into the environment.

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

Add the following method inside the class in NotificationService.swift:

private func updateText(request: UNNotificationRequest) {
  guard let 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)"
}

Notice how, if any piece of the required payload is missing, or in an incorrect format, the categoryIdentifier is blanked out. It doesn’t make sense to let the custom UI code get called when it would simply fail. Two separate guard clauses are required, as Swift will not allow access to bestAttemptContent in the failure condition of a guard clause where that variable is checked.

Using ISO8601 dates is very convenient, as Apple has provided a parser explicitly for that format! As long as the payload contains all the expected keys in the proper date formats, the body is updated to include the title and the date range. With dates, you’ll always want to utilize the provided classes, such as DateIntervalFormatter to ensure that the user’s locale is properly respected.

While nothing needs to be done with the invitation ID, you still want to ensure that it exists in the payload so that you know there’s valid content to pass to the Content Service Extension.

All that’s left to do in this file is to call both of the methods that you just implemented from didReceive just before the completion handler is called. Your final didReceive(_:withContentHandler) method should now look like this:

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

guard let bestAttemptContent else {
  return
}

defer { contentHandler(bestAttemptContent) }

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

updateBadge()
updateText(request: request)

Phew! There’s almost more text explaining what to do than it actually takes to do it! You can see how modifying the payload might seem daunting at first, but you can take significant action with very little code. The net benefit to your end users is a much better experience, which always makes the little bit of extra effort worth it.

In a production app there are other considerations you might want to take into account, such as:

  • What about all-day events?
  • What about recurring events?
  • What if the title is an empty string?
  • What happens if you send a start date that comes after an end date?

This is a great time to build and run the app again and send yourself a push notification. As a reminder, here’s a payload you can test with:

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

You can do this with the PushNotifications tester app as described in Chapter 5, “Sending Your First Push Notification.” You can find the payload at the start of this chapter.

Has the body of the text message been updated properly? If there’s no change, make sure you remembered to set mutable-content to 1 in the aps part of the payload. If it’s still not working, refer back to Chapter 10, “Modifying the Payload”, for help.

Those goodies in your kitchen aren’t going to eat themselves. You’ve done some great work so grab yourself a snack, take a quick break and then it’ll be time to work on the user interface.

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.

Considering what the goals of the UI will be leads to the following five tasks:

  1. Set the Info.plist details related to the category that you’re using.
  2. Add the newly arrived invitation to CalendarKit.
  3. Display all events happening at the same time as the new event.
  4. If the user accepts the invite, add it to iOS’s calendar.
  5. If the user comments, update the server.

It probably seems a bit silly to list out such simple tasks, but thinking of the UI tasks ahead of time helps to break down what, at first, seems like a daunting challenge into manageable pieces that you can focus on.

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”. Inside NSExtensionAttributes, 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.

Since the calendar itself will contain the details of the notification, it’s definitely not desirable to have iOS display the body of the notification in the UI. I know, I know… right now you’re thinking to yourself, “What?! Then why did I just edit the body of the notification to be human readable?” Remember that you might have had to disable the custom UI portion. If it gets disabled, you’d still want a nice text message. If it’s not, then you want the visual UI.

Create a new Boolean key, under NSExtensionAttributes, named UNNotificationExtensionDefaultContentHidden and set the value to YES.

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.

In NotificationViewController.swift’s didReceive(_:), after you do the parsing, you’ll want to send the invitation details into CalendarKit. Add the below code just after the line that adds the timeline container as a subview:

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.

Start off by creating a property for the event store to the top of NotificationViewController:

private let eventStore = EKEventStore()

Add the following code to the bottom of didRecieve(_:), before the commented out lines:

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

In the above code, you first determine what time is two hours before the invitation and two hours after. You then search for all of the user’s calendar events in that window of time using a predicate. Finally, you add all of those events to the calendar view using CalendarKit.

In a production app, you’d need to do some extra checks to see how long the appointment is, for example, or whether it’s an all-day event; you’d then need to modify the times accordingly. Never just add seconds to a date thinking it’s the right thing to do. Always use the built-in calendrical calculations that Foundation provides so that you don’t get caught by leap years, leap seconds, missing midnight hours and a slew of other time-related issues.

After adding the above code, uncomment the commented-out lines related to the timelineContainer. Those lines are necessary to make CalendarKit work properly but, until displayStart and displayEnd were defined, they would have resulted in confusing compiler errors.

Build and run the app, and send yourself another push notification. When you long-press into the notification, you should see a UI showing the time slot for the new event, as well as any events you might have planned at the same time.

You can play with the time in the payload to test out different appointments.

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.

If the invitation is declined, you’ll probably want to update your server so it can process the event, and eventually you’ll dismiss the UI. There’s now an issue to consider: The didReceive(_:completionHandler:) method, which responds to the action buttons, has no idea what the event is. You’ll fix that by adding another property to the class:

private var calendarIdentifier: Int?

Then set that in didReceive(_:) just after decoding the payload.

calendarIdentifier = id

You can now implement the didReceive(_:completionHandler:) method to start handling the actions by adding the following to the bottom of the UNNotificationContentExtension extension:

func didReceive(
  _ response: UNNotificationResponse
) async -> UNNotificationContentExtensionResponseOption {
  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.
    return .doNotDismiss
  }

  switch choice {
  case .accept, .decline:
    return .dismissAndForwardAction
  case .comment:
    return .doNotDismiss
  }
}

Are you getting a compiler error that ActionIdentifier is unknown? You know the drill! Add Custom UI to its target membership.

If the user chooses to enter a comment, bring up the keyboard and tell the completion handler that the UI window should stay active. If they accept or decline the invitation, the window can simply be dismissed.

You’re using a new option here, called dismissAndForward, which tells the UI to dismiss, while also forwarding the notification onto your primary app, triggering the userNotificationCenter(_:didReceive:) method.

Because this app wants to display the responses to each invite in a table, it’s necessary to store the response in a Core Data entity. While it’s entirely possible to create a new entity in an extension target, it’s not easy to know that it happened in the main target. Both targets would use the same root context from the NSPersistentContainer, and Foundation’s notifications don’t cross app targets. For this reason, it’s simpler to leave the Core Data work to the primary app target. You’ll handle that in just a bit.

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.

Add the following override to the top of NotificationViewController:

override var canBecomeFirstResponder: Bool {
  return true
}

When a keyboard appears from iOS, there’s no way for you to get access to the UITextField that is presented. Since you need to know when the Return button is pressed, it’s therefore necessary to replace the UITextField Apple provides with one of your own. The starter project has already created the keyboardInputAccessoryView for you for just this purpose.

By embedding the text field inside of another view, you can give some shading to the outer view, making the text field easier to see. Remember that you’re using full UIKit-based controls here, so you can add as many features as you need such as buttons and date pickers to suggest new times. Just always keep the user experience in mind as you add more controls.

The delegate has to be set on the keyboardTextField so that you can catch when the user taps the Return button on the keyboard to dismiss it.

Add the following to the top of textFieldShouldReturn(_:):

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

Something that’s not immediately obvious until after you’ve done some UI testing is that the comment text the end user typed won’t automatically disappear as the same UITextField is utilized each time the keyboard appears.

That’s why it’s necessary to set the text field’s text property to nil to properly clear it when you’re done.

Of course, for iOS to know that you want to actually do something with the UIView that you just created, you’ve got to tell it so!

Add this override to the top of the class:

override var inputAccessoryView: UIView? {
  return keyboardInputAccessoryView
}

All that’s left to do is to display the keyboard when the Comment button is tapped inside the switch in the async version of didReceive(_:):

case .comment:
  _ = becomeFirstResponder()
  _ = keyboardTextField.becomeFirstResponder()
  return .doNotDismiss

Build and run.

Send yourself some events and you should see your snazzy new custom UI with usable buttons!

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

If the user accepts or declines the invitation, you’ll need to store that response in your Core Data model. Add the following method to the class:

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

    try? context.save()
  }
}

For this example app you’ll just ignore any save errors, which is why you used try?. In a real app you’d likely want to take some action if the save were to fail.

Now you’ll need to take appropriate action based on the user’s response to the invitation. Add the following method to the UNUserNotificationCenterDelegate extension:

@MainActor
func userNotificationCenter(
  _ center: UNUserNotificationCenter,
  didReceive response: UNNotificationResponse
) async {
  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
  }
}

The parsing is no different than before, but notice that there are a couple extra “dot walks” that have to happen to get to the userInfo property. Even though you know for a fact that everything will parse properly, it’s never a good idea to use force unwrapping if there’s another way; the guard syntax is a better choice here.

All that’s left to do is to update the server with the user’s decision and create a Core Data entity, which will then automatically be displayed in the table on the main view.

To do this, add the following code to the bottom of the above method:

switch choice {
case .accept:
  Server.shared.acceptInvitation(with: calendarIdentifier)
  await createInvite(with: title, starting: startDate, ending: endDate, accepted: true)
case .decline:
  Server.shared.declineInvitation(with: calendarIdentifier)
  await 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:

There are three distinct piece to the row:

  1. The thumbs up or thumbs down image depending on whether or not the invitation was accepted.
  2. The name of the invitation.
  3. The date range of the invitation.

Create a new SwiftUI View file in your main target called AcceptanceImage.swift, so that you can display the proper thumb image:

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

Remember that in SwiftUI View objects are cheap, and Apple recommends breaking your views into smaller chunks. The preceding code will take a Bool to the constructor and then either display a green thumbs up or a red thumbs down.

Apple has provided over 4,000 configurable images which are all available for your app to use. You can see all of the available symbols by looking at developer.apple.com/sf-symbols.

Now that you are able to display the proper thumb, it’s time to create the rest of the cell. Create a new SwiftUI View file in your main target called InviteRow.swift.

You’ll be working with your Core Data entities so add the appropriate import to the top of the file:

import CoreData

Since the intent of the row is to display the invitation details, add a variable to hold the invitation at the top of the struct:

let invite: Invite

Xcode is now unhappy that the preview isn’t passing the required initializer parameter, so create a fake invitation by replacing the entire preview with the following code:

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 InviteRow(invite: invite)
    .previewLayout(.fixed(width: 300, height: 100))
}

The code performs the following actions:

  1. You are creating a new Core Data object in the preview container, not the shared container. The preview container uses the in-memory store for the simulator.
  2. Generate any random data. While adding 3,600 seconds to a date in order to add an hour is always wrong in production code, it’s perfectly fine for a preview.
  3. Call the initializer with the newly generated Core Data object.

Now it’s time to format the cell. Replace the default Text element in the body:

// 1
VStack(alignment: .leading) {
  // 2
  HStack {
    AcceptanceImage(accepted: invite.accepted)
    // 3
    Text(invite.title!)
      .font(.headline)
  }
}
  1. Use a VStack since you want two lines in the cell. If you don’t specify a .leading alignment the lines will be centered, which wouldn’t look correct.
  2. Your thumb image and the title should be side by side, thus the HStack.
  3. Core data properties are always optionals, regardless of what you specified in the data model. Because you made the model attribute non-optional it’s safe to force unwrap the title.

To display the date range you’ll make use of a formatter. Define a formatter at the top of the file, just below the import statements:

private let dateFormatter: DateIntervalFormatter = {
  let formatter = DateIntervalFormatter()
  formatter.dateStyle = .short
  formatter.timeStyle = .short
  return formatter
}()

Never display dates directly, always use a formatter. Because you’re showing a range of time DateIntervalFormatter is the proper class to use. Remember that Apple has specified SwiftUI View objects are cheap to create. That means they may be destroyed and created multiple times. It’s therefore important that you place the formatter outside of the struct so that you aren’t constantly recreating the formatter.

Now that you can show a date range properly, add the date just after the HStack:

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>

Then replace the default Text with a list displaying invitations, using the row you just created:

List(invites) { InviteRow(invite: $0) }

Build and run the app. Send yourself a few invites. When you open the app, you should see a list of accepted and declined appointments.

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 14 in our video tutorial, Your First iOS & SwiftUI App: An App from Scratch, available at bit.ly/3AYqomC.

If you need to brush up on your Core Data skills, take a look at Core Data by Tutorials, available at bit.ly/3oXTv1e.

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 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.
© 2026 Kodeco Inc.