Build a ride sharing iOS app with push notifications

neo

Neo

Posted on May 24, 2018

Build a ride sharing iOS app with push notifications

To follow this tutorial you will need a Mac with Xcode installed, knowledge of Xcode and Swift, basic knowledge of JavaScript (including Node.js), a Pusher account, and Cocoapods installed on your machine.

Ride sharing applications like Uber and Lyft let passengers request a ride from drivers in their area. When a passenger requests a ride, the application finds a driver as quickly as possible. If the passenger closes the app while they wait, they need a way to be notified that a car is on its way and again once it’s arrived.

In this article, we will be creating a simple make-believe Ride Sharing application with a focus on how you can integrate Pusher’s Beams API to deliver transactional push notifications.

We will be making two iOS applications to cater to the driver and the rider and a Node.js application to power them both. We will then add push notifications to alert the driver that a new ride request is available, and the passenger that they have a driver on their way, and once they arrive.

Prerequisites

Once you have the requirements, let’s start.

About our applications

Through the course of this tutorial, we will be making three applications:

  • The backend application (Web using Node.js). This will be the power house of both iOS applications. It will contain all the endpoints required for the application to function properly. It will also be responsible for sending the push notifications to the respective devices.
  • The rider application (iOS using Swift). This will be the application the rider will use to request rides.
  • The driver application (iOS using Swift). This will be the application the driver will use to accept requests from riders. The driver will be able to update the status of the ride as the situation warrants.

Here is a screen recording of what we will have when we are done:

App Demo

💡 We will not be focusing too much on the Ride Sharing functionality but we will be focusing mostly on how you can integrate push notifications to the application.

Building the backend application (API)

The first thing we want to build is the API. We will be adding everything required to support our iOS applications and then add push notifications later on.

To get started, create a project directory for the API. In the directory, create a new file called package.json and in the file paste the following:

{
  "main": "index.js",
  "scripts": {},
  "dependencies": {
    "body-parser": "^1.18.2",
    "express": "^4.16.2",
    "pusher": "^1.5.1",
    "pusher-push-notifications-node": "^0.10.1"
  }
}
Enter fullscreen mode Exit fullscreen mode

Next run the command below in your terminal:

$ npm install
Enter fullscreen mode Exit fullscreen mode

This will install all the listed dependencies. Next, create an index.js file in the same directory as the package.json file and paste in the following code:

// --------------------------------------------------------
// Pull in the libraries
// --------------------------------------------------------

const app = require('express')()
const bodyParser = require('body-parser')
const config = require('./config.js')
const Pusher = require('pusher')
const pusher = new Pusher({
    appId: 'PUSHER_APP_ID',
    key: 'PUSHER_APP_KEY',
    secret: 'PUSHER_APP_SECRET',
    cluster: 'PUSHER_APP_CLUSTER',
    encrypted: true
})

// --------------------------------------------------------
// In-memory database
// --------------------------------------------------------

let rider = null
let driver = null
let user_id = null
let status = "Neutral"

// --------------------------------------------------------
// Express Middlewares
// --------------------------------------------------------

app.use(bodyParser.json())
app.use(bodyParser.urlencoded({extended: false}))

// --------------------------------------------------------
// Helpers
// --------------------------------------------------------

function uuidv4() {
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
        var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
        return v.toString(16);
    });
}

// --------------------------------------------------------
// Routes
// --------------------------------------------------------

// ----- Rider --------------------------------------------

app.get('/status', (req, res) => res.json({ status }))

app.get('/request', (req, res) => res.json(driver))

app.post('/request', (req, res) => {
    user_id = req.body.user_id
    status = "Searching"
    rider = { name: "Jane Doe", longitude: -122.088426, latitude: 37.388064 }

    pusher.trigger('cabs', 'status-update', { status, rider })
    res.json({ status: true })

})
app.delete('/request', (req, res) => {
    driver = null
    status = "Neutral"
    pusher.trigger('cabs', 'status-update', { status })
    res.json({ status: true })
})

// ----- Driver ------------------------------------------

app.get('/pending-rider', (req, res) => res.json(rider))

app.post('/status', (req, res) => {
    status = req.body.status

    if (status == "EndedTrip" || status == "Neutral") {
        rider = driver = null
    } else {
        driver = { name: "John Doe" }
    }

    pusher.trigger('cabs', 'status-update', { status, driver })
    res.json({ status: true })
})

// ----- Misc ---------------------------------------------

app.get('/', (req, res) => res.json({ status: "success" }))

// --------------------------------------------------------
// Serve application
// --------------------------------------------------------

app.listen(4000, _ => console.log('App listening on port 4000!'))
Enter fullscreen mode Exit fullscreen mode

💡 You need to replace the PUSHER_APP_* keys with the real keys from the Pusher dashboard.

In the code above, we first pull in all the dependencies we need for the application to run. Next we set up some variables to hold data as an in-memory data store. We then define a UUID generator function which we will use to generate ID’s for objects. Next we define our applications routes:

  • POST /request saves a new request for a driver.
  • GET /request gets the driver that is handling the request.
  • DELETE /request cancels a request for a ride.
  • GET /pending-order gets the pending requests.
  • POST /status changes the status of a ride.

That’s all we need in the API for now and we will revisit it when we need to send push notifications. If you want to test that the API is working, then run the following command on your terminal:

$ node index.js
Enter fullscreen mode Exit fullscreen mode

This will start a new Node server listening on port 4000.

Building the Rider application

The next thing we need to do is build the client application. Launch Xcode and create a new ‘Single Application’ project. We will name our project RiderClient.

Once the project has been created, exit Xcode and create a new file called Podfile in the root of the Xcode project you just created. In the file paste in the following code:

platform :ios, '11.0'

target 'RiderClient' do
  use_frameworks!
  pod 'GoogleMaps', '~> 2.6.0'
  pod 'PusherSwift', '~> 5.1.1'
  pod 'Alamofire', '~> 4.6.0'
end
Enter fullscreen mode Exit fullscreen mode

In the file above, we specified the dependencies the project needs to run. Remember to change the target above to the name of your project. Now in your terminal, run the following command to install the dependencies:

$ pod install
Enter fullscreen mode Exit fullscreen mode

After the installation is complete, open the Xcode workspace file that was generated by Cocoapods. This will relaunch Xcode.

When Xcode has been relaunched, open the Main.storyboard file and in there we will create the storyboard for our client application. Below is a screenshot of how we have designed our storyboard:

Storyboard One

In the main View Controller, we have defined views that will display the status of the ride, the driver details and the CTA button.

💡 CTA is an abbreviation for call to action.

Create a new file in Xcode called MainController.swift, and make it the custom class for the main View Controller above. Next paste in the following code:

import UIKit
import Alamofire
import GoogleMaps

class MainViewController: UIViewController, GMSMapViewDelegate {
    var latitude = 37.388064
    var longitude = -122.088426
    var locationMarker: GMSMarker!

    @IBOutlet weak var mapView: GMSMapView!
    @IBOutlet weak var loadingIndicator: UIActivityIndicatorView!
    @IBOutlet weak var loadingOverlay: UIView!
    @IBOutlet weak var orderButton: UIButton!
    @IBOutlet weak var orderStatusView: UIView!
    @IBOutlet weak var orderStatus: UILabel!
    @IBOutlet weak var cancelButton: UIButton!
    @IBOutlet weak var driverDetailsView: UIView!

    override func viewDidLoad() {
        super.viewDidLoad()
        mapView.camera = GMSCameraPosition.camera(withLatitude:latitude, longitude:longitude, zoom:15.0)
        mapView.delegate = self
        locationMarker = GMSMarker(position: CLLocationCoordinate2D(latitude: latitude, longitude: longitude))
        locationMarker.map = mapView
        orderStatusView.layer.cornerRadius = 5
        orderStatusView.layer.shadowOffset = CGSize(width: 0, height: 0)
        orderStatusView.layer.shadowColor = UIColor.black.cgColor
        orderStatusView.layer.shadowOpacity = 0.3

        updateView(status: .Neutral, msg: nil)
    }
}
Enter fullscreen mode Exit fullscreen mode

In the code above we have the View Controller class. In the viewDidLoad we set up Google Maps, and call the updateView method. The updateView method is a helper function that simply alters the view displayed depending on the RideStatus. Add the method to the class:

private func updateView(status: RideStatus, msg: String?) {
    switch status {
    case .Neutral:
        driverDetailsView.isHidden = true
        loadingOverlay.isHidden = true
        orderStatus.text = msg != nil ? msg! : "💡 Tap the button below to get a cab."
        orderButton.setTitleColor(UIColor.white, for: .normal)
        orderButton.isHidden = false
        cancelButton.isHidden = true
        loadingIndicator.stopAnimating()

    case .Searching:
        loadingOverlay.isHidden = false
        orderStatus.text = msg != nil ? msg! : "🚕 Looking for a cab close to you..."
        orderButton.setTitleColor(UIColor.clear, for: .normal)
        loadingIndicator.startAnimating()
    case .FoundRide, .Arrival:
        driverDetailsView.isHidden = false
        loadingOverlay.isHidden = true

        if status == .FoundRide {
            orderStatus.text = msg != nil ? msg! : "😎 Found a ride, your ride is on it's way"
        } else {
            orderStatus.text = msg != nil ? msg! : "⏰ Your driver is waiting, please meet outside."
        }

        orderStatus.text = msg != nil ? msg! : "😎 Found a ride, your ride is on it's way"
        orderButton.isHidden = true
        cancelButton.isHidden = false
        loadingIndicator.stopAnimating()
    case .OnTrip:
        orderStatus.text = msg != nil ? msg! : "🙂 Your ride is in progress. Enjoy."
        cancelButton.isEnabled = false
    case .EndedTrip:
        orderStatus.text = msg != nil ? msg! : "🌟 Ride complete. Have a nice day!"
        orderButton.setTitleColor(UIColor.white, for: .normal)
        driverDetailsView.isHidden = true
        cancelButton.isEnabled = true
        orderButton.isHidden = false
        cancelButton.isHidden = true
    }
}
Enter fullscreen mode Exit fullscreen mode

Next we have the orderButtonPressed method that calls the sendRequest method which sends a request to the API. The next method is the cancelButtonPressed which also calls the sendRequest method.

@IBAction func orderButtonPressed(_ sender: Any) {
    updateView(status: .Searching, msg: nil)

    sendRequest(.post) { successful in
        guard successful else {
            return self.updateView(status: .Neutral, msg: "😔 No drivers available.")
        }
    }
}

@IBAction func cancelButtonPressed(_ sender: Any) {
    sendRequest(.delete) { successful in
        guard successful == false else {
            return self.updateView(status: .Neutral, msg: nil)
        }
    }
}

private func sendRequest(_ method: HTTPMethod, handler: @escaping(Bool) -> Void) {
    let url = AppConstants.API_URL + "/request"
    let params = ["user_id": AppConstants.USER_ID]

    Alamofire.request(url, method: method, parameters: params)
        .validate()
        .responseJSON { response in
            guard response.result.isSuccess,
                let data = response.result.value as? [String:Bool],
                let status = data["status"] else { return handler(false) }

            handler(status)
        }
}
Enter fullscreen mode Exit fullscreen mode

Next, let’s add some Pusher functionality to the View Controller so it can pick up changes to the RideStatus in realtime.

First, you need to import the Pusher swift SDK:

import PusherSwift
Enter fullscreen mode Exit fullscreen mode

Then define the pusher variable at the top of the class:

let pusher = Pusher(
    key: AppConstants.PUSHER_API_KEY,
    options: PusherClientOptions(host: .cluster(AppConstants.PUSHER_API_CLUSTER))
)
Enter fullscreen mode Exit fullscreen mode

Next, add the following method to the class:

private func listenForUpdates() {
    let channel = pusher.subscribe("cabs")

    let _ = channel.bind(eventName: "status-update") { data in
        if let data = data as? [String:AnyObject] {
            if let status = data["status"] as? String, 
            let rideStatus = RideStatus(rawValue: status) {
                self.updateView(status: rideStatus, msg: nil)
            }
        }
    }

    pusher.connect()
}
Enter fullscreen mode Exit fullscreen mode

The method above just subscribes to a Pusher channel and binds to the status-update event on the channel. When the event is triggered, the updateView method is called.

Finally at the bottom of the viewDidLoad method, add a call to the listenForUpdates method:

listenForUpdates()
Enter fullscreen mode Exit fullscreen mode

Now when the backend application triggers a status update event, our rider application will pick it up and change the UI as necessary.

Setting up Google Maps

Next, open your AppDelegate class and import the following:

import GoogleMaps
Enter fullscreen mode Exit fullscreen mode

Next you can replace the application(didFinishLaunchingWithOptions:) method with the following code:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    GMSServices.provideAPIKey(AppConstants.GOOGLE_API_KEY)
    return true
}
Enter fullscreen mode Exit fullscreen mode

Defining secret keys and ride status

Create a new file called AppConstants.swift and paste the following code in:

import Foundation

class AppConstants {
    static let GOOGLE_API_KEY = "GOOGLE_MAPS_API_KEY"
    static let PUSHER_API_KEY = "PUSHER_APP_KEY"
    static let PUSHER_API_CLUSTER = "PUSHER_APP_CLUSTER"
    static let API_URL = "http://127.0.0.1:4000"
    static let USER_ID = UUID().uuidString
}
Enter fullscreen mode Exit fullscreen mode

⚠️ You need to replace the placeholders above with the actual values from their respective dashboards.

Next, create a file called RideStatus.swift this will be where we will define all the available ride statuses:

import Foundation

enum RideStatus: String {
    case Neutral = "Neutral"
    case Searching = "Searching"
    case FoundRide = "FoundRide"
    case Arrived = "Arrived"
    case OnTrip = "OnTrip"
    case EndedTrip = "EndedTrip"
}
Enter fullscreen mode Exit fullscreen mode

That's all for the client application. Let’s move on to creating the Rider application.

One last thing we need to do though is modify the info.plist file. We need to add an entry to the plist file to allow connection to our local server:

plist screenshot

Let’s move on to the rider application.

Building the Driver application

Launch Xcode and create a new ‘Single Application’ project. We will name our project RiderDriver.

Once the project has been created, exit Xcode and create a new file called Podfile in the root of the Xcode project you just created. In the file paste in the following code:

platform :ios, '11.0'

target 'RiderDriver' do
  use_frameworks!
  pod 'PusherSwift', '~> 5.1.1'
  pod 'Alamofire', '~> 4.6.0'
  pod 'GoogleMaps', '~> 2.6.0'
  pod 'PushNotifications'
end
Enter fullscreen mode Exit fullscreen mode

In the file above, we specified the dependencies the project needs to run. Remember to change the target above to the name of your project. Now in your terminal, run the following command to install the dependencies:

$ pod install
Enter fullscreen mode Exit fullscreen mode

After the installation is complete, open the Xcode workspace file that was generated by Cocoapods. This will relaunch Xcode.

When Xcode has been relaunched, open the Main.storyboard file and in there we will create the storyboard for our client application. Below is a screenshot of how we have designed our storyboard:

Storyboard Two

In the main View Controller, we have defined views that will display the rider information and buttons needed to change the status of the ride. We also have a hidden view that will be displayed when there are no pending requests.

Create a new file in Xcode called MainController.swift, and make it the custom class for the main View Controller above. Next paste in the following code:

import UIKit
import Alamofire
import GoogleMaps

class MainViewController: UIViewController, GMSMapViewDelegate {
    var status: RideStatus!
    var locationMarker: GMSMarker!

    @IBOutlet weak var riderName: UILabel!    
    @IBOutlet weak var mapView: GMSMapView!
    @IBOutlet weak var requestView: UIView!
    @IBOutlet weak var noRequestsView: UIView!
    @IBOutlet weak var cancelButton: UIButton!
    @IBOutlet weak var statusButton: UIButton!

    override func viewDidLoad() {
        super.viewDidLoad()
        status = .Neutral
        requestView.isHidden = true
        cancelButton.isHidden = true
        noRequestsView.isHidden = false
        Timer.scheduledTimer(
            timeInterval: 2,
            target: self,
            selector: #selector(findNewRequests),
            userInfo: nil,
            repeats: true
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

The viewDidLoad sets the initial setting for the UI. Then we register a timer that fires the findNewRequests method every 2 seconds. Let’s define that method. Add the method below to the class:

@objc private func findNewRequests() {
    guard status == .Neutral else { return }

    Alamofire.request(AppConstants.API_URL + "/pending-rider")
        .validate()
        .responseJSON { response in
            guard response.result.isSuccess,
                let data = response.result.value as? [String:AnyObject] else { return }

            self.loadRequestForRider(Rider(data: data))
        }
}
Enter fullscreen mode Exit fullscreen mode

The method will send a request to the backend and if there is a pending request, it loads it to the UI. It however does not fire the request unless the ride status is Neutral.

Next lets define the loadRequestsForRider method that is called when there is a pending ride request:

private func loadRequestForRider(_ rider: Rider) {
    mapView.camera = GMSCameraPosition.camera(withLatitude:rider.latitude, longitude:rider.longitude, zoom:15.0)
    mapView.delegate = self

    locationMarker = GMSMarker(position: CLLocationCoordinate2D(latitude: rider.latitude, longitude: rider.longitude))
    locationMarker.map = mapView

    status = .Searching
    cancelButton.isHidden = false
    statusButton.setTitle("Accept Trip", for: .normal)

    riderName.text = rider.name
    requestView.isHidden = false
    noRequestsView.isHidden = true
}  
Enter fullscreen mode Exit fullscreen mode

The method simply loads Google Maps using the longitude and latitude of the rider making the request. Then it also prepares the UI to display the request.

The next methods to define will be the methods that change the status of the ride and update the UI depending on various events:

private func sendStatusChange(_ status: RideStatus, handler: @escaping(Bool) -> Void) {
    let url = AppConstants.API_URL+"/status"
    let params = ["status": status.rawValue]

    Alamofire.request(url, method: .post, parameters: params).validate()
        .responseJSON { response in
            guard response.result.isSuccess,
                let data = response.result.value as? [String: Bool] else { return handler(false) }

            handler(data["status"]!)
        }
}

private func getNextStatus(after status: RideStatus) -> RideStatus {
    switch self.status! {
    case .Neutral,
            .Searching: return .FoundRide
    case .FoundRide: return .Arrived
    case .Arrived: return .OnTrip
    case .OnTrip: return .EndedTrip
    case .EndedTrip: return .Neutral
    }
}

@IBAction func cancelButtonPressed(_ sender: Any) {
    if status == .FoundRide || status == .Searching {
        sendStatusChange(.Neutral) { successful in
            if successful {
                self.status = .Neutral
                self.requestView.isHidden = true
                self.noRequestsView.isHidden = false
            }
        }
    }
}

@IBAction func statusButtonPressed(_ sender: Any) {
    let nextStatus = getNextStatus(after: self.status)

    sendStatusChange(nextStatus) { successful in
        self.status = self.getNextStatus(after: nextStatus)

        switch self.status! {
        case .Neutral, .Searching:
            self.cancelButton.isHidden = true
        case .FoundRide:
            self.cancelButton.isHidden = false
            self.statusButton.setTitle("Announce Arrival", for: .normal)
        case .Arrived:
            self.cancelButton.isHidden = false
            self.statusButton.setTitle("Start Trip", for: .normal)
        case .OnTrip:
            self.cancelButton.isHidden = true
            self.statusButton.setTitle("End Trip", for: .normal)
        case .EndedTrip:
            self.status = .Neutral
            self.noRequestsView.isHidden = false
            self.requestView.isHidden = true
            self.statusButton.setTitle("Accept Trip", for: .normal)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The sendStatusChange is a helper method that sends requests to the API to change the status of a ride. The getNextStatus is a helper method that returns the next RideStatus in line from the one passed to it.

The cancelButtonPressed is fired when the cancel button is pressed and it requests the ride be canceled. Finally, the statusButtonPressed just sends a request to change the status based on the current status of the ride. It also updates the UI to fit the status it was changed to.

Next, let’s add some Pusher functionality to the View Controller so it can pick up changes to the RideStatus in realtime.

First, you need to import the Pusher swift SDK:

import PusherSwift
Enter fullscreen mode Exit fullscreen mode

Then define the pusher variable at the top of the class:

let pusher = Pusher( 
    key: AppConstants.PUSHER_API_KEY, 
    options: PusherClientOptions(host: .cluster(AppConstants.PUSHER_API_CLUSTER)) 
)
Enter fullscreen mode Exit fullscreen mode

Next, add the following method to the class:

private func listenForStatusUpdates() {
    let channel = pusher.subscribe(channelName: "cabs")

    let _ = channel.bind(eventName: "status-update") { data in
        if let data = data as? [String: AnyObject] {
            if let status = data["status"] as? String, let rideStatus = RideStatus(rawValue: status) {
                if rideStatus == .Neutral {
                    self.status = .Neutral
                    self.cancelButtonPressed(UIButton())
                }
            }
        }
    }

    pusher.connect()
}
Enter fullscreen mode Exit fullscreen mode

The method above just subscribes to a Pusher channel and binds to the status-update event on the channel. When the event is triggered, the cancel button function is called.

Finally at the bottom of the viewDidLoad method, add a call to the listenForStatusUpdates method:

listenForStatusUpdates()
Enter fullscreen mode Exit fullscreen mode

Now when the backend application triggers a status update event, our application will pick it up and change the UI as necessary.

Setting up Google Maps

Next, open your AppDelegate class and import the following:

import GoogleMaps
Enter fullscreen mode Exit fullscreen mode

Next you can replace the application(didFinishLaunchingWithOptions:) method with the following code:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    GMSServices.provideAPIKey(AppConstants.GOOGLE_API_KEY)
    return true
}
Enter fullscreen mode Exit fullscreen mode

Defining secret keys and ride status

Create a new file called AppConstants.swift and paste the following code in:

class AppConstants {
    static let GOOGLE_API_KEY = "GOOGLE_API_KEY"
    static let PUSHER_KEY = "PUSHER_API_KEY"
    static let PUSHER_CLUSTER = "PUSHER_API_CLUSTER"
    static let API_URL = "http://127.0.0.1:4000"
    static let PUSH_NOTIF_INSTANCE_ID = "PUSHER_NOTIFICATION_INSTANCE_ID"
    static let USER_ID = UUID().uuidString
}
Enter fullscreen mode Exit fullscreen mode

⚠️ You need to replace the placeholders above with the actual values from their respective dashboards.

Next, create two files called Rider.swift and RideStatus.swift then paste the following code into the files:

// Rider.swift
import Foundation

struct Rider {
    let name: String
    let longitude: Double
    let latitude: Double

    init(data: [String:AnyObject]) {
        self.name = data["name"] as! String
        self.longitude = data["longitude"] as! Double
        self.latitude = data["latitude"] as! Double
    }
}


// RideStatus.swift
import Foundation

enum RideStatus: String {
    case Neutral = "Neutral"
    case Searching = "Searching"
    case FoundRide = "FoundRide"
    case Arrived = "Arrived"
    case OnTrip = "OnTrip"
    case EndedTrip = "EndedTrip"
}
Enter fullscreen mode Exit fullscreen mode

That's all for the rider application. One last thing we need to do though is modify the info.plist file as we did in the client application.

Now we have created the applications and you can run them to see them in action. However, we have not added push notifications to the application. We need to do this so that the user can know there is an event on the service when the application is minimised.

Let’s set up push notifications.

Adding push notifications to our iOS applications

The first thing we need to do is make our server capable of sending push notifications.

At this point, the application works as expected out of the box. We now need to add push notifications to the application to make it more engaging even when the user is not currently using the application.

⚠️ You need to be enrolled to the Apple Developer program to be able to use the Push Notifications feature. Also Push Notifications do not work on Simulators so you will need an actual iOS device to test.

Pusher’s Beams API has first-class support for native iOS applications. Your iOS app instances subscribe to Interests ; then your servers send push notifications to those interests. Every app instance subscribed to that interest will receive the notification, even if the app is not open on the device at the time.

This section describes how you can set up an iOS app to receive transactional push notifications about your food delivery orders through Pusher.

Configure APNs

Pusher relies on Apple Push Notification service (APNs) to deliver push notifications to iOS application users on your behalf. When we deliver push notifications, we use your APNs Key. This page guides you through the process of getting an APNs Key and how to provide it to Pusher.

Head over to the Apple Developer dashboard by clicking here and then create a new Key as seen below:

Create a new key gif

When you have created the key, download it. Keep it safe as we will need it in the next section.

⚠️ You have to keep the generated key safe as you cannot get it back if you lose it.

Creating your Pusher application

The next thing you need to do is create a new Pusher Push Notification application from the Pusher dashboard.

Create a Pusher notifications instance

When you have created the application, you should be presented with a Quickstart wizard that will help you set up the application.

In order to configure Push Notifications you will need to get an APNs key from Apple. This is the same key as the one we downloaded in the previous section. Once you’ve got the key, upload it to the Quickstart wizard.

Add APN key

Enter your Apple Team ID. You can get the Team ID from here. Click on the continue to proceed to the next step.

Updating your Rider application to support push notifications

In your client application, if you haven’t already, open the Podfile and add the following pod to the list of dependencies:

pod 'PushNotifications'
Enter fullscreen mode Exit fullscreen mode

Now run the pod install command as you did earlier to pull in the notifications package. Next open the AppDelegate class and import the PushNotifications package:

import PushNotifications
Enter fullscreen mode Exit fullscreen mode

Now, as part of the AppDelegate class, add the following:

let pushNotifications = PushNotifications.shared

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
  // [...]

  self.pushNotifications.start(instanceId: "PUSHER_NOTIF_INSTANCE_ID")
  self.pushNotifications.registerForRemoteNotifications()

  // [...]

  return true
}

func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
  // [...]

  self.pushNotifications.registerDeviceToken(deviceToken) {
    try? self.pushNotifications.subscribe(interest: "rider_\(AppConstants.USER_ID)")
  }

  // [...]
}
Enter fullscreen mode Exit fullscreen mode

💡 Replace PUSHER_PUSH_NOTIF_INSTANCE_ID with the key given to you by the Pusher application.

In the code above, we set up push notifications in the application(didFinishLaunchingWithOptions:) method and then we subscribe to the interest in the application(didRegisterForRemoteNotificationsWithDeviceToken:) method.

The dynamic interest demos how you can easily use specific interests for specific devices or users. As long as the server pushes to the correct interest, you can rest assured that devices subscribed to the interest will get the push notification.

Next, we need to enable push notifications for the application. In the project navigator, select your project, and click on the Capabilities tab. Enable Push Notifications by turning the switch ON.

Push Notifications - Slide On

Updating your Driver application to support Push notifications

Your rider application also needs to be able to receive Push Notifications. The process is similar to the set up above. The only difference will be the interest we will be subscribing to in AppDelegate which will be ride_requests.

Adding rich actions to our push notifications on iOS

As it currently stands, our application will be able to receive push notifications but let’s take it one step further and add rich actions to the application. This will add more engagement to the notification.

Rich actions

First, open the AppDelegate class and import the following classes:

import PushNotifications import UserNotifications
Enter fullscreen mode Exit fullscreen mode

Next, you need to extend the AppDelegate with the `` class. Then add the following code:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    // [...]    

    let center = UNUserNotificationCenter.current()
    center.delegate = self

    let cancelAction = UNNotificationAction(
        identifier: "cancel", 
        title: "Reject", 
        options: [.foreground]
    )

    let acceptAction = UNNotificationAction(
        identifier: "accept", 
        title: "Accept Request", 
        options: [.foreground]
    )

    let category = UNNotificationCategory(
        identifier: "DriverActions", 
        actions: [acceptAction, cancelAction], 
        intentIdentifiers: []
    )

    center.setNotificationCategories([category])

    // [...]

    return true
}
Enter fullscreen mode Exit fullscreen mode

In the code above, we are specifying the actions we want our push notifications to display.

In the same AppDelegate class, add the following method which will handle the actions when they are selected on the push notification:

func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
    let name = Notification.Name("status")

    if response.actionIdentifier == "cancel" {
        NotificationCenter.default.post(name: name, object: nil, userInfo: ["status": RideStatus.Neutral])
    }

    if response.actionIdentifier == "accept" {
        NotificationCenter.default.post(name: name, object: nil, userInfo: ["status": RideStatus.FoundRide])
    }

    completionHandler()
}
Enter fullscreen mode Exit fullscreen mode

In the code, we just send a local notification when the push notification action is tapped. Next, we will add an observer in our view controller that will trigger some code when the notification is received.

Open the MainViewController class and add the following code in the viewDidLoad method:

NotificationCenter.default.addObserver(
    self,
    selector: #selector(changeStatusFromPushNotification),
    name: Notification.Name("status"),
    object: nil
)
Enter fullscreen mode Exit fullscreen mode

Next, add the changeStatusFromPushNotification method to the class:

@objc private func changeStatusFromPushNotification(notification: Notification) {
    guard
        let data = notification.userInfo as? [String: RideStatus],
        let status = data["status"] else { return }

    sendStatusChange(status) { successful in
        guard successful else { return }

        if status == .Neutral {
            self.status = .FoundRide
            self.cancelButtonPressed(UIButton())
        }

        if status == .FoundRide {
            self.status = .Searching
            self.statusButtonPressed(UIButton())
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This callback just triggers the sendStatusChange method that we have already defined earlier in the tutorial.

Creating our notification service extension

Next, we need to create our Notification Service Extension.

💡 When receiving a notification in an iOS app, you may want to be able to download content in response to it or edit the content before it is shown to the user. In iOS 10, Apple now allows apps to do this through a new Notification Service Extension. - Codetuts

In Xcode, go to File > New > Target… and select Notification Service Extension then give the target a name and click Done.

xcode new notification

If you look in the file browser in Xcode, you should see the new target added with two new files: NotificationService.swift and info.plist. We will be modifying these files to make sure it gets and provides the right information for our push notification.

Open the NotificationService class and replace the didReceive method with the following:

override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
    self.contentHandler = contentHandler
    bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)

    func failEarly() {
        contentHandler(request.content)
    }

    guard
        let content = (request.content.mutableCopy() as? UNMutableNotificationContent),
        let apnsData = content.userInfo["data"] as? [String: Any],
        let mapURL = apnsData["attachment-url"] as? String,
        let attachmentURL = URL(string: mapURL.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!),
        let imageData = try? NSData(contentsOf: attachmentURL, options: NSData.ReadingOptions()),
        let attachment = UNNotificationAttachment.create(imageFileIdentifier: "image.png", data: imageData, options: nil)
    else {
        return failEarly()
    }

    content.attachments = [attachment]
    contentHandler(content.copy() as! UNNotificationContent)
}
Enter fullscreen mode Exit fullscreen mode

In the code above, we try to get the content of the push notification. Since we want to display the map in the notification, we are expecting a static map URL from the custom data of the push notification. We use that and serve it as an attachment which we add the to content of the push. We finally pass the content to the contentHandler.

Next, add the following extension to the same file:

extension UNNotificationAttachment {

    static func create(imageFileIdentifier: String, data: NSData, options: [NSObject : AnyObject]?) -> UNNotificationAttachment? {
        let fileManager = FileManager.default
        let tmpSubFolderName = ProcessInfo.processInfo.globallyUniqueString
        let tmpSubFolderURL = NSURL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(tmpSubFolderName, isDirectory: true)

        do {
            try fileManager.createDirectory(at: tmpSubFolderURL!, withIntermediateDirectories: true, attributes: nil)
            let fileURL = tmpSubFolderURL?.appendingPathComponent(imageFileIdentifier)
            try data.write(to: fileURL!, options: [])
            let imageAttachment = try UNNotificationAttachment(identifier: imageFileIdentifier, url: fileURL!, options: options)
            return imageAttachment
        } catch let error {
            print("error \(error)")
        }

        return nil
    }
}
Enter fullscreen mode Exit fullscreen mode

The create method saves the static map to a temporary location on the device so it does not have to load from a URL.

One final change we want to make is in the info.plist file. Here we want to register all the action identifiers for the push notification. Open the info.plist file and add the following as highlighted in the image below;

plist again

That’s all we need to do on the application side. Now we need to make sure the API sends the push notifications.

Sending push notifications from our Node.js API

In the Node.js project, open our index.js file and import the push notification package:

const PushNotifications = require('pusher-push-notifications-node')
const pushNotifications = new PushNotifications({
    instanceId: 'YOUR_INSTANCE_ID_HERE',
    secretKey: 'YOUR_SECRET_KEY_HERE'
})
Enter fullscreen mode Exit fullscreen mode

💡 You should replace the placeholder values with the values from your Pusher dashboard.

Next, add the following helper functions:

function sendRiderPushNotificationFor(status) {
    switch (status) {
        case "Neutral":
            var alert = {
                "title": "Driver Cancelled :(",
                "body": "Sorry your driver had to cancel. Open app to request again.",
            }
            break;
        case "FoundRide":
            var alert = {
                "title": "🚕 Found a ride",
                "body": "The driver is on the way."
            }
            break;
        case "Arrived":
            var alert = {
                "title": "🚕 Driver is waiting",
                "body": "The driver outside, please meet him."                
            }
            break;
        case "OnTrip":
            var alert = {
                "title": "🚕 You are on your way",
                "body": "The driver has started the trip. Enjoy your ride."
            }
            break;
        case "EndedTrip":
            var alert = {
                "title": "🌟 Ride complete",
                "body": "Your ride cost $15. Open app to rate the driver."
            }
            break;
    }
    if (alert != undefined) {
        pushNotifications.publish(['rider'], {apns: {aps: {alert, sound: "default"}}})
            .then(resp => console.log('Just published:', resp.publishId))
            .catch(err => console.log('Error:', err))
    }
}

function sendDriverPushNotification() {
    pushNotifications.publish(['ride_requests'], {
        "apns": {
            "aps": {
                "alert": {
                    "title": "🚗 New Ride Request",
                    "body": `New pick up request from ${rider.name}.`,
                },
                "category": "DriverActions",
                "mutable-content": 1,
                "sound": 'default'
            },
            "data": {
                "attachment-url": "https://maps.google.com/maps/api/staticmap?markers=color:red|37.388064,-122.088426&zoom=13&size=500x300&sensor=true"
            }
        }
    })
    .then(response => console.log('Just published:', response.publishId))
    .catch(error => console.log('Error:', error));
}
Enter fullscreen mode Exit fullscreen mode

Above we have two functions. The first is sendRiderPushNotificationFor which sends a notification to the rider based on the status of the trip. The second method is the sendDriverPushNotification which just sends a notification to the driver.

In the sendDriverPushNotification we can see the format for the push notification is a little different than the first. This is because we are supporting rich actions so we have to specify the category key and the mutable-content key. The category must match the name we specified in the AppDelegate.

Next, you need to call the functions above in their respective routes. The first function should be added to the POST /status route above the pusher.trigger method call. The second function should be called in the POST /request route above the pusher.trigger method call.

Now, when we run our applications, we should get push notifications on our devices.

⚠️ When working with push notifications on iOS, the server must be served in HTTPS.

That’s all there is to adding push notifications using Pusher. Heres a screen recording of our applications in action:

Side by side apps demo

Conclusion

In this article, we created a basic ride sharing service and used that to demonstrate how to use Pusher to send push notifications with rich actions. Hopefully you learnt how you can use Pusher to simplify the process of sending Push Notifications to your users.

The source code to the repository is available on GitHub.

This post first appeared on Pusher blog.

💖 💪 🙅 🚩
neo
Neo

Posted on May 24, 2018

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related