Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions Sources/BetterFit/Models/HealthRecord.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import Foundation

// MARK: - HealthRecord

/// A single top-level `Record` element from an Apple Health export.
///
/// Workouts carry their own child statistics. Everything else HealthKit
/// tracks across the day, such as steps, heart rate samples, body mass,
/// and sleep, arrives as a flat `Record`. Quantity records carry a numeric
/// `value`. Category records, such as sleep analysis, carry a string value
/// instead, which is kept in `categoryValue`.
public struct HealthRecord: Codable, Equatable, Sendable {
public var type: String
public var sourceName: String?
public var sourceVersion: String?
public var unit: String?
public var creationDate: Date?
public var startDate: Date?
public var endDate: Date?
/// Numeric value for quantity records. Nil for category records.
public var value: Double?
/// Raw string value for category records. Nil for quantity records.
public var categoryValue: String?

public init(
type: String,
sourceName: String? = nil,
sourceVersion: String? = nil,
unit: String? = nil,
creationDate: Date? = nil,
startDate: Date? = nil,
endDate: Date? = nil,
value: Double? = nil,
categoryValue: String? = nil
) {
self.type = type
self.sourceName = sourceName
self.sourceVersion = sourceVersion
self.unit = unit
self.creationDate = creationDate
self.startDate = startDate
self.endDate = endDate
self.value = value
self.categoryValue = categoryValue
}

// MARK: - Kind

/// Coarse classification of the record type.
public enum Kind: Equatable, Sendable {
case stepCount
case heartRate
case restingHeartRate
case activeEnergy
case basalEnergy
case distanceWalkingRunning
case distanceCycling
case bodyMass
case sleepAnalysis
case other(String)
}

public var kind: Kind {
switch type {
case "HKQuantityTypeIdentifierStepCount":
return .stepCount
case "HKQuantityTypeIdentifierHeartRate":
return .heartRate
case "HKQuantityTypeIdentifierRestingHeartRate":
return .restingHeartRate
case "HKQuantityTypeIdentifierActiveEnergyBurned":
return .activeEnergy
case "HKQuantityTypeIdentifierBasalEnergyBurned":
return .basalEnergy
case "HKQuantityTypeIdentifierDistanceWalkingRunning":
return .distanceWalkingRunning
case "HKQuantityTypeIdentifierDistanceCycling":
return .distanceCycling
case "HKQuantityTypeIdentifierBodyMass":
return .bodyMass
case "HKCategoryTypeIdentifierSleepAnalysis":
return .sleepAnalysis
default:
return .other(type)
}
}

// MARK: - Normalised Values

/// Value expressed in kilocalories, or nil when this is not an energy record.
public var valueKilocalories: Double? {
guard kind == .activeEnergy || kind == .basalEnergy, let value else { return nil }
switch unit?.lowercased() {
case "kj":
return value / 4.184
case "j":
return value / 4184
case "kcal", "cal", nil:
return value
default:
return value
}
}

/// Value expressed in metres, or nil when this is not a distance record.
public var valueMetres: Double? {
guard kind == .distanceWalkingRunning || kind == .distanceCycling, let value else {
return nil
}
switch unit?.lowercased() {
case "km":
return value * 1000
case "mi":
return value * 1609.344
case "m", nil:
return value
default:
return value
}
}

/// Value expressed in kilograms, or nil when this is not a body mass record.
public var valueKilograms: Double? {
guard kind == .bodyMass, let value else { return nil }
switch unit?.lowercased() {
case "lb":
return value * 0.45359237
case "g":
return value / 1000
case "kg", nil:
return value
default:
return value
}
}

/// Time between the start and end dates, when both are present.
/// Sleep records use this for time in each sleep stage.
public var duration: TimeInterval? {
guard let startDate, let endDate else { return nil }
return endDate.timeIntervalSince(startDate)
}
}

// MARK: - HealthExport

/// A parsed Apple Health export, with workouts and top-level records.
public struct HealthExport: Codable, Equatable, Sendable {
public var workouts: [ParsedWorkout]
public var records: [HealthRecord]

public init(workouts: [ParsedWorkout] = [], records: [HealthRecord] = []) {
self.workouts = workouts
self.records = records
}

// MARK: - Record Lookup

public func records(of kind: HealthRecord.Kind) -> [HealthRecord] {
records.filter { $0.kind == kind }
}
}
70 changes: 57 additions & 13 deletions Sources/BetterFit/Services/HealthKit/HealthExportParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import FoundationXML

// MARK: - HealthExportParser

/// Parses workouts and their child `WorkoutStatistics` from an Apple Health
/// export (`export.xml`).
/// Parses workouts and top-level records from an Apple Health export
/// (`export.xml`).
///
/// The parser is Foundation only and runs on any platform, so export files
/// can be processed off device and in unit tests. It reads `Workout`
/// elements and the `WorkoutStatistics` nested inside them. Top-level
/// `Record` elements are out of scope for this slice and are skipped.
/// elements and the `WorkoutStatistics` nested inside them, plus the
/// top-level `Record` elements for steps, heart rate, energy, distance,
/// body mass, and sleep.
///
/// Example shape:
///
Expand All @@ -35,6 +36,38 @@ public enum HealthExportParser {

/// Parse workouts from export XML data.
public static func parseWorkouts(data: Data) throws -> [ParsedWorkout] {
try parse(data: data).workouts
}

/// Parse workouts from an export XML string.
public static func parseWorkouts(xml: String) throws -> [ParsedWorkout] {
try parseExport(xml: xml).workouts
}

/// Parse top-level records from export XML data.
public static func parseRecords(data: Data) throws -> [HealthRecord] {
try parse(data: data).records
}

/// Parse top-level records from an export XML string.
public static func parseRecords(xml: String) throws -> [HealthRecord] {
try parseExport(xml: xml).records
}

/// Parse a full export, with workouts and top-level records, from XML data.
public static func parseExport(data: Data) throws -> HealthExport {
try parse(data: data)
}

/// Parse a full export, with workouts and top-level records, from an XML string.
public static func parseExport(xml: String) throws -> HealthExport {
guard let data = xml.data(using: .utf8) else {
throw ParseError.malformedXML("input is not valid UTF-8")
}
return try parse(data: data)
}

private static func parse(data: Data) throws -> HealthExport {
let delegate = Delegate()
let parser = XMLParser(data: data)
parser.delegate = delegate
Expand All @@ -47,15 +80,7 @@ public enum HealthExportParser {
if let error = delegate.parseError {
throw error
}
return delegate.workouts
}

/// Parse workouts from an export XML string.
public static func parseWorkouts(xml: String) throws -> [ParsedWorkout] {
guard let data = xml.data(using: .utf8) else {
throw ParseError.malformedXML("input is not valid UTF-8")
}
return try parseWorkouts(data: data)
return HealthExport(workouts: delegate.workouts, records: delegate.records)
}

// MARK: - Date Parsing
Expand Down Expand Up @@ -130,6 +155,7 @@ public enum HealthExportParser {

private final class Delegate: NSObject, XMLParserDelegate {
private(set) var workouts: [ParsedWorkout] = []
private(set) var records: [HealthRecord] = []
private(set) var parseError: HealthExportParser.ParseError?
private var current: ParsedWorkout?

Expand All @@ -147,6 +173,8 @@ private final class Delegate: NSObject, XMLParserDelegate {
guard current != nil else { return }
let stat = Self.makeStatistic(attributes: attributeDict)
current?.statistics.append(stat)
case "Record":
records.append(Self.makeRecord(attributes: attributeDict))
default:
break
}
Expand Down Expand Up @@ -196,6 +224,22 @@ private final class Delegate: NSObject, XMLParserDelegate {
)
}

private static func makeRecord(attributes: [String: String]) -> HealthRecord {
let rawValue = attributes["value"]
let numericValue = rawValue.flatMap(Double.init)
return HealthRecord(
type: attributes["type"] ?? "",
sourceName: attributes["sourceName"],
sourceVersion: attributes["sourceVersion"],
unit: attributes["unit"],
creationDate: HealthExportParser.parseHealthDate(attributes["creationDate"]),
startDate: HealthExportParser.parseHealthDate(attributes["startDate"]),
endDate: HealthExportParser.parseHealthDate(attributes["endDate"]),
value: numericValue,
categoryValue: numericValue == nil ? rawValue : nil
)
}

private static func makeStatistic(attributes: [String: String]) -> WorkoutStatistics {
WorkoutStatistics(
type: attributes["type"] ?? "",
Expand Down
Loading
Loading