Episode #227

Let's Build Activity++ - Part 5

Series: Let's Build Activity++!

28 minutes
Published on July 7, 2016

This video is only available to subscribers. Get access to this video and 572 others.

Continuing our series on building out our Activity++ clone, this time we hook up our application to HealthKit, displaying real data in the app from a device. We continue to use our wrapper type so that the application can still work in the simulator with randomized data, which also allows us to set up certain scenarios that we wish to test, such as streaks.

Episode Links

Handing over the HealthStore instance to Our Main View Controller

I decided to do this in a more generic way that offers some flexibility over how our app is structured in the storyboard. Firstly, a protocol:

import Foundation
import HealthKit
import UIKit

protocol RequiresHealth {
    var healthStore: HKHealthStore? { get set }
}

We declare our conformance to this protocol in ActivityCollectionViewController:

import HealthKit

class ActivityCollectionViewController: UICollectionViewController, RequiresHealth {

    var healthStore: HKHealthStore?

    // ...
}    

Then, to allow UINavigationController-wrapped view controllers still participate in this without having to manually unpack a view controller from a navigation controller, we offer a default implementation:

extension UINavigationController : RequiresHealth {
    var healthStore: HKHealthStore? {
        get {
            if let vc = topViewController as? RequiresHealth {
                return vc.healthStore
            }
            return nil
        }
        set {
            if var vc = topViewController as? RequiresHealth {
                vc.healthStore = newValue
            }
        }
    }
}

The same could be done for UITabBarController or any other view controller container.

With this in place, it becomes easy to initialize this and pass it off to our view controller from our AppDelegate:

if HKHealthStore.isHealthDataAvailable() {
    healthStore = HKHealthStore()

    if var vc = window?.rootViewController as? RequiresHealth {
        vc.healthStore = healthStore
    }
}

Loading Health Data

First, we write a function that checks our current authorization for health data. If we don't have access, we request to read activity summary data:

func checkHealthAccess(completion: (Bool, NSError?) -> Void) {
        guard let healthStore = self.healthStore else {
            completion(false, nil)
            return
        }

        let status = healthStore.authorizationStatusForType(HKObjectType.activitySummaryType())
        switch status {
        case .SharingAuthorized: completion(true, nil)
        case .NotDetermined: fallthrough
        case .SharingDenied:
            healthStore.requestAuthorizationToShareTypes(nil, readTypes: [HKObjectType.activitySummaryType()],
                                                         completion: completion)

        }
}

Then we call this function in viewDidLoad:

        checkHealthAccess { (isAuthorized, error) in
            if let error = error {
                print("ERROR: \(error)")
            } else if isAuthorized {
                dispatch_async(dispatch_get_main_queue()) {
                    self.loadHealthSamples()
                    NSNotificationCenter.defaultCenter().addObserver(self,
                        selector: #selector(ActivityCollectionViewController.loadHealthSamples),
                        name: UIApplicationDidBecomeActiveNotification,
                        object: nil)
                }
            } else {
                print("Not authorized")
            }
        }

Note that we have to load samples when we come back to the foreground, so we respond to that notification and load again. The loadHealthSamples method is defined as:

    func loadHealthSamples() {
        activityIndicator.startAnimating()

        let calendar = NSCalendar.currentCalendar()
        let today = NSDate()

        let startDate = calendar.dateByAddingUnit(.Year, value: -1, toDate: today, options: [])!
        let unitFlags: NSCalendarUnit = [.Year, .Month, .Day]
        let startDateComponents = calendar.components(unitFlags, fromDate: startDate)
        startDateComponents.calendar = calendar

        let endDateComponents = calendar.components(unitFlags, fromDate: today)
        endDateComponents.calendar = calendar

        let summariesPastYear = HKQuery.predicateForActivitySummariesBetweenStartDateComponents(
            startDateComponents,
            endDateComponents: endDateComponents
        )

        let query = HKActivitySummaryQuery(predicate: summariesPastYear) { (query, summaries, error) in

            dispatch_async(dispatch_get_main_queue()) {
                self.activityIndicator.stopAnimating()
            }

            if let error = error {
                print("error fetching summaries: \(error)")
            } else {
                if let summaries = summaries {
                    dispatch_async(dispatch_get_main_queue()) {
                        self.dataSource = ActivityDataSource(calendar: calendar, summaries: summaries)
                        self.collectionView?.reloadData()
                    }
                }
            }
        }

        healthStore?.executeQuery(query)
    }

Building the Data Source

Our ActivityDataSource type is responsible for taking the data from HealthKit and transforming it into the ActivityLog type that we've been using so far:

struct ActivityDataSource {
    private let calendar: NSCalendar
    private let summaries: [HKActivitySummary]

    init(calendar: NSCalendar, summaries: [HKActivitySummary]) {
        self.calendar = calendar
        self.summaries = summaries
    }

    lazy var activities: [ActivityLog] = {
        return self.summaries.map { summary in
            return ActivityLog.fromHealthKitSummary(summary, calendar: self.calendar)
        }.sort { (x, y) in return x.date.compare(y.date) == NSComparisonResult.OrderedDescending }
    }()
}

This references a new method on ActivityLog:

extension ActivityLog {
    static func fromHealthKitSummary(summary: HKActivitySummary, calendar: NSCalendar) -> ActivityLog {
        let dateComponents = summary.dateComponentsForCalendar(calendar)
        let date = calendar.dateFromComponents(dateComponents)!
        let caloriesBurned = summary.activeEnergyBurned.integerValueForUnit(.calorieUnit()) / 1000
        let calorieGoal = summary.activeEnergyBurnedGoal.integerValueForUnit(.calorieUnit()) / 1000
        let exerciseMin = summary.appleExerciseTime.integerValueForUnit(.minuteUnit())
        let exerciseGoal = summary.appleExerciseTimeGoal.integerValueForUnit(.minuteUnit())
        let standHours = summary.appleStandHours.integerValueForUnit(.countUnit())
        let standGoal = summary.appleStandHoursGoal.integerValueForUnit(.countUnit())

        let log = ActivityLog(date: date,
                              caloriesBurned: caloriesBurned,
                              activityGoal: calorieGoal,
                              minutesOfExercise: exerciseMin,
                              exerciseGoal: exerciseGoal,
                              standHours: standHours,
                              standGoal: standGoal)
        return log
    }
}

In order to save a few keystrokes, we're using a tiny extension on HKQuantity:

extension HKQuantity {
    func integerValueForUnit(unit: HKUnit) -> Int {
        return Int(doubleValueForUnit(unit))
    }
}

We can also leverage an extension in a similar manner to extract the random sample logic into a better place:

extension ActivityLog {
    static func randomSampleWithDate(date: NSDate) -> ActivityLog {

        let caloriesBurned = Int(arc4random() % 800)
        let calorieGoal = 500

        let exerciseMin = Int(arc4random() % 45)
        let exerciseGoal = 30

        let standHours = Int(arc4random() % 16)
        let standGoal = 12

        return ActivityLog(date: date,
                           caloriesBurned: caloriesBurned,
                           activityGoal: calorieGoal,
                           minutesOfExercise: exerciseMin,
                           exerciseGoal: exerciseGoal,
                           standHours: standHours,
                           standGoal: standGoal)
    }
}

Finally, we update our collection view datasource methods to use this instead of the random samples and we now have our application reading data from HealthKit!