Create a cryptocurrency tracking app with push notifications using Swift and Laravel - Part 2: The iOS app

Introduction

In the first part of this article, we started developing our cryptocurrency alert application. We developed the backend of the application that will power the iOS application. As it stands, our backend application can return settings for a device based on its UUID, save the settings for a device based on its UUID and also it can figure out what devices to send push notifications to when the currencies update.

In this part, we will focus on creating the iOS application using Swift and Xcode.

Prerequisites

To follow along you need the following requirements:

What we will be building

We already started out by building the backend of the application using Laravel. So next, we will build the iOS application using Swift. If you want to test the push notifications then you will need to run the application on a live device.

How the client application will work

For the client app, the iOS application, we will create a simple list that will display the available currencies and the current prices to the dollar. Whenever the price of the cryptocurrency changes, we will trigger an event using Pusher Channels so the prices are updated.

From the application, you will be able to set a minimum and maximum price change when you want to be alerted. For instance, you can configure the application to send a push notification to the application when the price of one Etherium (ETH) goes below $500. You can also configure the application to receive a notification when the price of Bitcoin goes above $5000.

How the application will look

When we are done with the application, here's how the application will look:

ios-cryptocurrency-part-1-demo

Let’s get started.

Setting up your client application

Launch Xcode and click Create a new Xcode project. Select Single View App and click Next. Enter your Product Name, we will call our project cryptoalat, and select Swift from the Language options. You can also change any other detail you wish to on the screen then click Next.

Installing dependencies

Now you have your Xcode project. Close Xcode and open a terminal window. cd to the iOS project directory in terminal and run the command below to create a Podfile:

    $ pod init

The Podfile is a specification that describes the dependencies of the targets of one or more Xcode projects. The file should simply be named Podfile. All the examples in the guides are based on CocoaPods version 1.0 and onwards. - Cocoapods Guides

This will generate a new file called Podfile in the root of your project. Open this file in any editor and update the file as seen below:

1// File: Podfile
2    platform :ios, '11.0'
3    
4    target 'cryptoalat' do
5      use_frameworks!
6    
7      pod 'Alamofire', '~> 4.7.2'
8      pod 'PushNotifications', '~> 0.10.8'
9      pod 'PusherSwift', '~> 6.1.0'
10      pod 'NotificationBannerSwift', '~> 1.6.3'
11    end

If you used a project name other than cryptoalat, then change it in the Podfile to match your project’s target name.

Go to terminal and run the command below to install your dependencies:

    $ pod install

When the installation is complete, you will have a *.xcworkspace file in the root of your project. Open this file in Xcode and let’s start developing our cryptocurrency alert application.

Building the iOS application

Creating our storyboard

The first thing we need to do is design our storyboard for the application. This is what we want the storyboard to look like when we are done.

ios-cryptocurrency-part-2-storyboard

Open the Main.storyboard file and design as seen above.

Above we have three scenes. The first scene, which is the entry point, is the launch scene. We then draw a manual segue with an identifier called Main. Then we set the segue Kind to Present Modally. This will present the next scene which is a navigation view controller. Navigation controllers already have an attached root view controller by default.

We will use this attached view controller, which is a TableViewController, as the main view for our application. It’ll list the available currencies and show us a text field that allows us to change the setting for that currency when it is tapped.

On the third scene, we set the reuse identifier of the cells to coin and we drag two labels to the prototype cell. The first label will be for the coin name and the second label will be for the price.

Now that we have the scenes, let’s create some controllers and view classes and connect them to our storyboard scenes.

Creating your controllers

In Xcode, create a new class LaunchViewController and paste the contents of the file below into it:

1import UIKit
2    
3    class LaunchViewController: UIViewController {
4        
5        override func viewDidAppear(_ animated: Bool) {
6            super.viewDidAppear(animated)
7            
8            SettingsService.shared.loadSettings {
9                self.routeToMainController()
10            }
11        }
12    
13        fileprivate func routeToMainController() {
14            performSegue(withIdentifier: "Main", sender: self)
15        }
16    }

Set the controller as the custom class for the first scene in the Main.storyboard file.

In the code, we load the settings using a SettingsService class we will create later. When the settings are loaded for the device, we then call the routeToMainController method, which routes the application to the main controller using the Main segue we created earlier.

The next controller we will be creating will be the CoinsTableViewController. This will be the controller that will be tied to the third scene which is the main scene.

Create the CoinsTableViewController and replace the contents with the following code;

1import UIKit
2    import PusherSwift
3    import NotificationBannerSwift
4    
5    struct Coin {
6        let name: String
7        let rate: Float
8    }
9    
10    class CoinsTableViewController: UITableViewController {
11    
12        var coins: [Coin] = []
13        
14        override func viewDidLoad() {
15            super.viewDidLoad()
16        }
17        
18        override func numberOfSections(in tableView: UITableView) -> Int {
19            return 1
20        }
21    
22        override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
23            return coins.count
24        }
25    
26        override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
27            let coin = coins[indexPath.row]
28            let cell = tableView.dequeueReusableCell(withIdentifier: "coin", for: indexPath) as! CoinTableViewCell
29    
30            cell.name.text = "1 \(coin.name) ="
31            cell.amount.text = "$\(String(coin.rate))"
32    
33            return cell
34        }
35    }

Set the controller as the custom class for the first scene in the Main.storyboard file.

Above we have defined the Coin struct and it has a name and rate property. We have the controller which we define the coins property as an array of Coins. We then have some boilerplate code that comes with creating a table view controller.

The numberOfSections method specifies how many sections the table will have. In the first tableView method, we return the number of coins available and in the second tableView method, we define how we want each row to be handled.

Creating other supporting classes

If you noticed in the code above, we referenced a CoinTableViewCell as the class for each row in the last tableView method. Let’s create that.

Create a CoinTableViewCell class and paste the following code into it:

1class CoinTableViewCell: UITableViewCell {
2        @IBOutlet weak var name: UILabel!    
3        @IBOutlet weak var amount: UILabel!
4    }

Open the Main.storyboard file and set the class as the custom class for the prototype cell in the third scene of the Main.storyboard file. When you have set the class, connect the @IBOutlets as specified in the cell class above.

The next class we need to create is the SettingsService. This class will be responsible for updating and fetching the settings for the device.

Create a new SettingsService class and replace the contents with the following code:

1import Foundation
2    import Alamofire
3    import NotificationBannerSwift
4    
5    class SettingsService {
6        static let key = "CryptoAlat"
7        static let shared = SettingsService()
8        
9        var settings: Settings? {
10            get {
11                return self.getCachedSettings()
12            }
13            set(settings) {
14                if let settings = settings {
15                    self.updateCachedSettings(settings)
16                }
17            }
18        }
19        
20        private init() {}
21        
22        func loadSettings(completion: @escaping() -> Void) {
23            fetchRemoteSettings { settings in
24                guard let settings = settings else {
25                    return self.saveSettings(self.defaultSettings()) { _ in
26                        completion()
27                    }
28                }
29                
30                self.updateCachedSettings(settings)
31                completion()
32            }
33        }
34        
35        fileprivate func defaultSettings() -> Settings {
36            return Settings(
37                btc_min_notify: 0, 
38                btc_max_notify: 0, 
39                eth_min_notify: 0, 
40                eth_max_notify: 0
41            )
42        }
43        
44        func saveSettings(_ settings: Settings, completion: @escaping(Bool) -> Void) {
45            updateRemoteSettings(settings, completion: { saved in
46                if saved {
47                    self.updateCachedSettings(settings)
48                }
49                
50                completion(saved)
51            })
52        }
53        
54        fileprivate func fetchRemoteSettings(completion: @escaping (Settings?) -> Void) {
55            guard let deviceID = AppConstants.deviceIDFormatted else {
56                return completion(nil)
57            }
58    
59            let url = "\(AppConstants.API_URL)?u=\(deviceID)"
60            Alamofire.request(url).validate().responseJSON { resp in
61                if let data = resp.data, resp.result.isSuccess {
62                    let decoder = JSONDecoder()
63                    if let settings = try? decoder.decode(Settings.self, from: data) {
64                        return completion(settings)
65                    }
66                }
67                
68                completion(nil)
69            }
70        }
71        
72        fileprivate func updateRemoteSettings(_ settings: Settings, completion: @escaping(Bool) -> Void) {
73            guard let deviceID = AppConstants.deviceIDFormatted else {
74                return completion(false)
75            }
76            
77            let params = settings.toParams()
78            let url = "\(AppConstants.API_URL)?u=\(deviceID)"
79            Alamofire.request(url, method: .post, parameters: params).validate().responseJSON { resp in
80                guard resp.result.isSuccess, let res = resp.result.value as? [String: String] else {
81                    return StatusBarNotificationBanner(title: "Failed to update settings.", style: .danger).show()
82                }
83                
84                completion((res["status"] == "success"))
85            }
86        }
87        
88        fileprivate func updateCachedSettings(_ settings: Settings) {
89            if let encodedSettings = try? JSONEncoder().encode(settings) {
90                UserDefaults.standard.set(encodedSettings, forKey: SettingsService.key)
91            }
92        }
93        
94        fileprivate func getCachedSettings() -> Settings? {
95            let defaults = UserDefaults.standard
96            if let data = defaults.object(forKey: SettingsService.key) as? Data {
97                let decoder = JSONDecoder()
98                if let decodedSettings = try? decoder.decode(Settings.self, from: data) {
99                    return decodedSettings
100                }
101            }
102            
103            return nil
104        }
105    }

Above we have the SettingsService. The first method loadSettings loads the settings from the API and then saves it locally. If there is no setting remotely, it calls the defaultSettings method and saves the response to the API.

The saveSettings method saves the Settings remotely using updateRemoteSettings and then locally using updateCachedSettings. The fetchRemoteSettings gets the settings from the API and decodes the response using the Swift decodable API.

Next, let’s define the Settings struct and have it extend Codable. In the same file for the SettingsService, add this above the SettingsService class definition:

1struct Settings: Codable {
2        var btc_min_notify: Int?
3        var btc_max_notify: Int?
4        var eth_min_notify: Int?
5        var eth_max_notify: Int?
6        
7        func toParams() -> Parameters {
8            var params: Parameters = [:]
9            
10            if let btcMin = btc_min_notify { params["btc_min_notify"] = btcMin }
11            if let btcMax = btc_max_notify { params["btc_max_notify"] = btcMax }
12            if let ethMin = eth_min_notify { params["eth_min_notify"] = ethMin }
13            if let ethMax = eth_max_notify { params["eth_max_notify"] = ethMax }
14    
15            return params
16        }
17    }

Above we have a simple Settings struct that conforms to Codable. We also have a toParams method that converts the properties to a Parameters type so we can use it with Alamofire when making requests.

One last class we need to create is AppConstants. We will use this class to keep all the data that we expect to remain constant and unchanged throughout the lifetime of the application.

Create a AppConstants file and paste the following code:

1import UIKit
2    
3    struct AppConstants {
4        static let API_URL = "http://127.0.0.1:8000/api/settings"
5        static let deviceID = UIDevice.current.identifierForVendor?.uuidString
6        static let deviceIDFormatted = AppConstants.deviceID?.replacingOccurrences(of: "-", with: "_").lowercased()
7        static let PUSHER_INSTANCE_ID = "PUSHER_BEAMS_INSTANCE_ID"
8        static let PUSHER_APP_KEY = "PUSHER_APP_KEY"
9        static let PUSHER_APP_CLUSTER = "PUSHER_APP_CLUSTER"
10    }

Replace the PUSHER_* keys with the values gotten from the Pusher Channels and Beams dashboard.

Updating the settings for the device

Now that we have defined the settings service, let’s update our controller so the user can set the minimum and maximum prices for each currency.

Open the CoinsTableViewController class and add the following method:

1override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
2        let coin = coins[indexPath.row]
3    
4        var minTextField: UITextField?
5        var maxTextField: UITextField?
6    
7        let title = "Manage \(coin.name) alerts"
8        let message = "Notification will be sent to you when price exceeds or goes below minimum and maximum price. Set to zero to turn off notification."
9    
10        let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
11    
12        alert.addTextField { textfield in
13            minTextField = textfield
14            textfield.placeholder = "Alert when price is below"
15        }
16    
17        alert.addTextField { textfield in
18            maxTextField = textfield
19            textfield.placeholder = "Alert when price is above"
20        }
21    
22        alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
23    
24        alert.addAction(UIAlertAction(title: "Save", style: .default, handler: { action in
25            guard let minPrice = minTextField?.text, let maxPrice = maxTextField?.text else {
26                return StatusBarNotificationBanner(title: "Invalid min or max price", style: .danger).show()
27            }
28    
29            var btcMin: Int?, btcMax: Int?, ethMin: Int?, ethMax: Int?
30    
31            switch coin.name {
32            case "BTC":
33                btcMin = Int(minPrice)
34                btcMax = Int(maxPrice)
35            case "ETH":
36                ethMin = Int(minPrice)
37                ethMax = Int(maxPrice)
38            default:
39                return
40            }
41    
42            let settings = Settings(
43                btc_min_notify: btcMin,
44                btc_max_notify: btcMax,
45                eth_min_notify: ethMin,
46                eth_max_notify: ethMax
47            )
48    
49            SettingsService.shared.saveSettings(settings, completion: { saved in
50                if saved {
51                    StatusBarNotificationBanner(title: "Saved successfully").show()
52                }
53            })
54        }))
55    
56        present(alert, animated: true, completion: nil)
57    }

The method above is automatically called when a row is selected. In this method, we display a UIAlertController with two text fields for the minimum price and the maximum price. When the prices are submitted, the SettingsService we created earlier takes care of updating the values both locally and remotely.

Adding realtime cryptocurrency update support

Open the CoinsTableViewController and add the pusher property to the class as seen below:

    var pusher: Pusher!

Then replace the viewDidLoad method with the following code:

1override func viewDidLoad() {
2        super.viewDidLoad()
3    
4        pusher = Pusher(
5            key: AppConstants.PUSHER_APP_KEY, 
6            options: PusherClientOptions(host: .cluster(AppConstants.PUSHER_APP_CLUSTER))
7        )
8    
9        let channel = pusher.subscribe("currency-update")
10    
11        let _ = channel.bind(eventName: "currency.updated") { data in
12            if let data = data as? [String: [String: [String: Float]]] {
13                guard let payload = data["payload"] else { return }
14    
15                self.coins = []
16    
17                for (coin, deets) in payload {
18                    guard let currentPrice = deets["current"] else { return }
19                    self.coins.append(Coin(name: coin, rate: currentPrice))
20                }
21    
22                Dispatch.main.async {
23                    self.tableView.reloadData()
24                }
25            }
26        }
27    
28        pusher.connect()
29    }

In the code above, we are using the Pusher Swift SDK to subscribe to our currency-update Pusher Channel. We then subscribe to the currency.updated event on that channel. Whenever that event is triggered, we refresh the price of the cryptocurrency in realtime.

Adding push notifications to our iOS new application

To add push notification support, open the AppDelegate class and replace the contents with the following:

1import UIKit
2    import PushNotifications
3    
4    @UIApplicationMain
5    class AppDelegate: UIResponder, UIApplicationDelegate {
6    
7        var window: UIWindow?
8        
9        func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
10            PushNotifications.shared.start(instanceId: AppConstants.PUSHER_INSTANCE_ID)
11            PushNotifications.shared.registerForRemoteNotifications()
12            return true
13        }
14    
15        func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
16            PushNotifications.shared.registerDeviceToken(deviceToken) {
17                if let deviceID = AppConstants.deviceIDFormatted {
18                    try? PushNotifications.shared.subscribe(interest: "\(deviceID)_eth_changed")
19                    try? PushNotifications.shared.subscribe(interest: "\(deviceID)_btc_changed")
20                }
21            }
22        }
23    }

In the class above, we use the Pusher Beams Swift SDK to register the device for push notifications. We then subscribe to the *_eth_changed and *_btc_changed interests, where * is the device’s unique UUID.

Now that we have completed the logic for the application, let’s enable push notifications on the application in Xcode.

In the project navigator, select your project, and click on the Capabilities tab. Enable Push Notifications by turning the switch ON.

ios-cryptocurrency-part-2-enable-push

This will create an entitlements file in the root of your project. With that, you have provisioned your application to fully receive push notifications.

Allowing our application to connect locally

If you are going to be testing the app’s backend using a local server, then there is one last thing we need to do. Open the info.plist file and add an entry to the plist file to allow connection to our local server:

ios-cryptocurrency-part-2-local-connection

That’s all. We can run our application. However, remember that to demo the push notifications, you will need an actual iOS device as simulators cannot receive push notifications. If you are using a physical device, you’ll need to expose your local API using Ngrok and then change the API_URL In AppConstants.

Anytime you want to update the currency prices, run the command below manually in your Laravel application:

    $ php artisan schedule:run

Here is a screen recording of the application in action:

ios-cryptocurrency-part-1-demo

Conclusion

In this article, we have been able to see how easy it is to create a cryptocurrency alert website using Laravel, Swift, Pusher Channels and Pusher Beams. The source code to the application built in this article is available on GitHub.