Watch App Development Blog - Week 3

January 25, 2015 - alamofire core-location ios swift

In weeks 1 & 2, I got a Transperth-scraping REST API built and deployed to an AWS-based cloud host. This week I’ll get started on:

Step 3: The iPhone App

Third party apps on Apple Watch are very limited and rely heavily on the companion iPhone app for logic, network access, location etc, so the first place to start with the Watch app is on the iPhone.

The phone app itself will need useful functionality otherwise it's unlikely to be approved. I have a few ideas for cool features for the phone, but for the moment it can just display the live times for the nearest station. This will require:

  1. Getting the list of stations from the server
  2. Finding the nearest station based on the user’s current location
  3. Getting the live times for that station from the server.

Let’s get started. I’m going to be building the app in Swift (of course). Mattt Thompson of NSHipster/AFNetworking fame has written a Swift-only networking framework called Alamofire, so we’ll start with it.

After setting up the framework following the instructions, I created a new Swift file called ‘ApiClient’. Downloading JSON from the server using Alamofire looks like the following:

func getAllStations(f: [Station] -> ()) {
    Alamofire.request(.GET, "\(hostname)/train")
        .responseJSON { (_, _, json, _) in
           if let json = json as? [NSDictionary] {
                let s = json.map({ Station(dictionary: $0) })
                f(s)
            } else { f([]) }
    }
}

struct Station {
    let id : String
    let name : String
    let location: CLLocation

    init(dictionary: NSDictionary) {
        self.id = dictionary["id"] as String
        self.name = dictionary["name"] as String
        let lat = Double(dictionary["lat"] as NSNumber)
        let long = Double(dictionary["long"] as NSNumber)
        self.location = CLLocation(latitude: lat, longitude: long)
    }
}

I’m using a global function (to be more functional) with a callback parameter that takes an array of stations. The returned JSON array is mapped over to convert the NSDictionary instances into Station values. If anything goes wrong, an empty array is passed to the callback - this isn’t brilliant error handling and will probably change, but it’s clearer to show as-is for now.

The user’s location can then be retrieved from a CLLocationManager, and the nearest location calculated like so:

func nearestStation(loc: CLLocation) -> Station? {
    return stations.filter({ $0.distanceFrom(loc) <= 1500 }).sorted({ $0.distanceFrom(loc) &< $1.distanceFrom(loc) }).first
}

// where distanceFrom is defined on Station as
func distanceFrom(otherLocation: CLLocation) -> CLLocationDistance {
    return location.distanceFromLocation(otherLocation)
}

This will filter out all stations greater than 1.5km away, and return the nearest of the remainder (or nil if there are no nearby stations).

From there, we can retrieve the live times from the server using the code:

func getLiveTrainTimes(station: String, f: [Departure] -> ()) {
    Alamofire.request(.GET, "\(hostname)/train/\(station)")
        .responseJSON { (_, _, json, _) in
            if let json = json as? [NSDictionary] {
                let s = json.map({ Departure(dictionary: $0) })
                f(s)
            } else { f([]) }
    }
}

Wait - this looks pretty much identical to getAllStations, just with a different URL and return type. Let's refactor:

protocol JSONConvertible {
    init(dictionary: NSDictionary)
}

func get<T: JSONConvertible>(path: String, f: [T] ->()) {
    Alamofire.request(.GET, hostname + path)
        .responseJSON { (_, _, json, _) in
        if let json = json as? [NSDictionary] {
            f(json.map({ T(dictionary: $0) }))
        } else { f([]) }
    }
}

// we can then redefine the other request methods as:
func getAllStations(f: [Station] -> ()) {
    get("/train", f)
}

func getLiveTrainTimes(station: String, f: [Departure] -> ()) {
    get("/train/\(station)", f)
}

The UI for the app (which I won’t go through here) is just a regular UITableView with a row for each train at the nearest station. However, it’s worth considering how the app will operate at different phases of its lifecycle - I want the live times to be updated in the background (for reasons that will become apparent later).

While the app is in the foreground, the location updates will come through as normal - I have a 50m distance filter on as this is unlikely to change your nearest station. When entering the background, the app will switch to the ‘significant change’ location service - again, accuracy is not super important so the course-grained significant change should work fine.

For the live train times network requests, in the foreground these will be triggered by a 30s NSTimer, in the background using the background fetch API. I haven’t used background fetch before, but it seems like the right technology to use in the case - allow the OS to decide if the app should refresh its data, based on battery life, network connectivity, and usage patterns of the app.

The various services are switched on & off like so:

func applicationDidBecomeActive(application: UIApplication) {
    locationManager.startUpdatingLocation()
    timer = NSTimer.scheduledTimerWithTimeInterval(NSTimeInterval(30), target: self, selector: "timerFired:", userInfo: nil, repeats: true)
    timer?.fire()
}

func applicationWillResignActive(application: UIApplication) {
    locationManager.stopUpdatingLocation()
    timer?.invalidate()
    timer = nil
}

func applicationDidEnterBackground(application: UIApplication) {
    locationManager.startMonitoringSignificantLocationChanges()
}

func applicationWillEnterForeground(application: UIApplication) {
    locationManager.stopMonitoringSignificantLocationChanges()
}

This is enough to start road-testing the app functionality out on the phone, and maybe start formulating a few ideas around the best functionality to prioritise on the watch. Speaking of the watch, next week I’ll have a surprise along those lines. As before, the code is available on bitbucket.