Build a Fitness Tracker: HealthKit Integration and Swift Charts


Violet logs her morning runs. Dash tracks his sprints — every blazing one of them. Mr. Incredible monitors his bench press sets and keeps a suspicious eye on his step count. They all want one app to show it all, beautifully charted. That app is what you’re about to build.

In this tutorial, you’ll build The Incredibles Family Fitness Tracker — a SwiftUI dashboard that reads real health data from HealthKit, charts weekly step trends and heart rate history using Swift Charts, and displays animated ring-style progress indicators. You’ll learn how to request HealthKit authorization, wrap asynchronous queries in a clean data layer, and compose rich data visualizations with BarMark, LineMark, and SectorMark.

Prerequisites

  • Xcode 16+ with an iOS 18 deployment target
  • A physical iPhone or a simulator with Health data seeded (see Getting Started)
  • Familiarity with SwiftUI state management
  • Familiarity with async/await

Note: HealthKit is available on iPhone and Apple Watch only. It is not available on iPad or macOS (without Mac Catalyst workarounds). The simulator supports HealthKit queries but you must manually add sample data via the Health app simulation.

Contents

Getting Started

Open Xcode and create a new project using the App template.

  1. Set the Product Name to IncrediblesFitness.
  2. Set Interface to SwiftUI and Language to Swift.
  3. Set Minimum Deployments to iOS 18.0.
  4. Choose a location to save the project and click Finish.

The finished app will have this file structure:

IncrediblesFitness/
├── IncrediblesFitnessApp.swift
├── ContentView.swift
├── Managers/
│   └── HealthKitManager.swift
├── Models/
│   └── FitnessModels.swift
├── Views/
│   ├── DashboardView.swift
│   ├── StepsRingView.swift
│   ├── WeeklyChartView.swift
│   ├── HeartRateChartView.swift
│   ├── MonthlyBreakdownView.swift
│   └── WorkoutLogView.swift

Create those folders and empty Swift files now so Xcode’s navigator stays organized. You can leave each file with just import SwiftUI until the relevant step.

Seeding simulator data: If you’re running on a simulator, open the Health app inside the simulator, navigate to Browse → Activity → Steps, and use the Add Data button to add a few weeks of step counts. Do the same for Heart Rate and Active Energy. This ensures your charts have something to render.

Step 1: Adding the HealthKit Capability

Before writing a single line of HealthKit code, you need to add the capability and the required privacy keys — without them, the app will crash at launch.

Adding the entitlement:

  1. Select the IncrediblesFitness target in the project navigator.
  2. Click the Signing & Capabilities tab.
  3. Click the + button and add HealthKit.

Xcode automatically creates IncrediblesFitness.entitlements with com.apple.developer.healthkit set to true.

Adding the Info.plist privacy key:

HealthKit requires you to explain to the user why you need access to their health data. Open Info.plist (or add the key via Info tab of your target) and add:

KeyValue
NSHealthShareUsageDescriptionThe Incredibles Fitness Tracker reads your steps, heart rate, and workouts to display your family's fitness progress.

If you intend to write data back to HealthKit (which this tutorial does not cover), you’d also add NSHealthUpdateUsageDescription.

Warning: Apple will reject your App Store submission if NSHealthShareUsageDescription is missing. Even in development, missing this key causes a crash with a descriptive error in the console.

Step 2: Requesting HealthKit Authorization

HKHealthStore is the central object for all HealthKit interactions. You create one instance per app — it’s expensive to instantiate and acts as a gateway for both authorization and queries.

Create a new file at Managers/HealthKitManager.swift. This class will own the HKHealthStore, handle authorization, and expose async methods for fetching data.

Start with the authorization request:

import HealthKit

@Observable
final class HealthKitManager {
    // One shared store for the lifetime of the app
    private let store = HKHealthStore()

    // The health data types we want to read
    private let readTypes: Set<HKObjectType> = [
        HKObjectType.quantityType(forIdentifier: .stepCount)!,
        HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!,
        HKObjectType.quantityType(forIdentifier: .heartRate)!,
        HKObjectType.workoutType()
    ]

    var isAuthorized = false
    var authorizationError: Error?

    func requestAuthorization() async {
        // HealthKit is not available on all devices (e.g., iPad without workaround)
        guard HKHealthStore.isHealthDataAvailable() else {
            return
        }

        do {
            try await store.requestAuthorization(toShare: [], read: readTypes)
            isAuthorized = true
        } catch {
            authorizationError = error
        }
    }
}

A few things worth noting here. The readTypes set uses force-unwrap because these identifiers are known at compile time and will never be nil — this is one of the rare cases where ! is justified. We’re passing an empty Set as toShare because this app only reads data, never writes it. The @Observable macro means SwiftUI views will automatically re-render when isAuthorized or authorizationError change.

Now wire up the authorization request from the app’s entry point. Open IncrediblesFitnessApp.swift:

import SwiftUI

@main
struct IncrediblesFitnessApp: App {
    @State private var healthKitManager = HealthKitManager()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(healthKitManager)
                .task {
                    await healthKitManager.requestAuthorization()
                }
        }
    }
}

Using .task here means the authorization request fires as soon as the view appears, without blocking the main thread.

Checkpoint: Build and run the app on a device or simulator. You should see a HealthKit permission sheet slide up asking for access to Steps, Active Energy, Heart Rate, and Workouts. Tap Allow All to grant access. If the sheet never appears, double-check that NSHealthShareUsageDescription is in your Info.plist and the HealthKit entitlement is enabled.

Step 3: Building the HealthKit Manager

With authorization in place, you can now query actual health data. HealthKit offers several query types; this tutorial uses two:

  • HKStatisticsCollectionQuery — aggregates samples over a date range into daily/weekly buckets. Perfect for step counts and active energy.
  • HKSampleQuery — fetches individual samples. Used here for heart rate readings.

Add these methods to HealthKitManager. First, define the data models that the manager will vend. Open Models/FitnessModels.swift:

import Foundation

struct DailySteps: Identifiable {
    let id = UUID()
    let date: Date
    let count: Double
}

struct HeartRateSample: Identifiable {
    let id = UUID()
    let date: Date
    let beatsPerMinute: Double
}

struct WorkoutRecord: Identifiable {
    let id = UUID()
    let date: Date
    let activityType: String
    let durationMinutes: Double
    let caloriesBurned: Double
    let distanceKilometers: Double?
}

Now add the query methods back in HealthKitManager.swift. Add these stored properties near the top:

var todaySteps: Double = 0
var weeklySteps: [DailySteps] = []
var heartRateSamples: [HeartRateSample] = []
var workouts: [WorkoutRecord] = []
var activeEnergyToday: Double = 0

Then add the step count query. HKStatisticsCollectionQuery is callback-based, so we bridge it to async/await using withCheckedContinuation:

func fetchWeeklySteps() async {
    guard let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount) else { return }

    let calendar = Calendar.current
    let now = Date()
    // Start of 7 days ago
    guard let startDate = calendar.date(byAdding: .day, value: -6, to: calendar.startOfDay(for: now)) else { return }

    let anchorDate = calendar.startOfDay(for: now)
    let daily = DateComponents(day: 1)
    let predicate = HKQuery.predicateForSamples(withStart: startDate, end: now, options: .strictStartDate)

    let samples: [DailySteps] = await withCheckedContinuation { continuation in
        let query = HKStatisticsCollectionQuery(
            quantityType: stepType,
            quantitySamplePredicate: predicate,
            options: .cumulativeSum,
            anchorDate: anchorDate,
            intervalComponents: daily
        )

        query.initialResultsHandler = { _, results, _ in
            var dailyData: [DailySteps] = []
            results?.enumerateStatistics(from: startDate, to: now) { stats, _ in
                let steps = stats.sumQuantity()?.doubleValue(for: .count()) ?? 0
                dailyData.append(DailySteps(date: stats.startDate, count: steps))
            }
            continuation.resume(returning: dailyData)
        }

        store.execute(query)
    }

    weeklySteps = samples
    todaySteps = samples.last?.count ?? 0
}

Now add the heart rate query. Heart rate is stored as individual samples in beats per minute, so HKSampleQuery is the right tool:

func fetchHeartRate(days: Int = 7) async {
    guard let hrType = HKQuantityType.quantityType(forIdentifier: .heartRate) else { return }

    let now = Date()
    guard let startDate = Calendar.current.date(byAdding: .day, value: -days, to: now) else { return }
    let predicate = HKQuery.predicateForSamples(withStart: startDate, end: now, options: .strictStartDate)
    let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: true)

    let samples: [HeartRateSample] = await withCheckedContinuation { continuation in
        let query = HKSampleQuery(
            sampleType: hrType,
            predicate: predicate,
            limit: 200,         // cap at 200 readings to keep the chart readable
            sortDescriptors: [sortDescriptor]
        ) { _, results, _ in
            let hrSamples = (results as? [HKQuantitySample])?.map { sample in
                HeartRateSample(
                    date: sample.endDate,
                    // HealthKit stores heart rate as count/min
                    beatsPerMinute: sample.quantity.doubleValue(for: HKUnit(from: "count/min"))
                )
            } ?? []
            continuation.resume(returning: hrSamples)
        }
        store.execute(query)
    }

    heartRateSamples = samples
}

Add one more method to fetch today’s active energy:

func fetchActiveEnergyToday() async {
    guard let energyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) else { return }

    let now = Date()
    let startOfDay = Calendar.current.startOfDay(for: now)
    let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now, options: .strictStartDate)

    let calories: Double = await withCheckedContinuation { continuation in
        let query = HKStatisticsQuery(
            quantityType: energyType,
            quantitySamplePredicate: predicate,
            options: .cumulativeSum
        ) { _, result, _ in
            let value = result?.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0
            continuation.resume(returning: value)
        }
        store.execute(query)
    }

    activeEnergyToday = calories
}

/// Convenience method to refresh all data at once
func fetchAll() async {
    await withTaskGroup(of: Void.self) { group in
        group.addTask { await self.fetchWeeklySteps() }
        group.addTask { await self.fetchHeartRate() }
        group.addTask { await self.fetchActiveEnergyToday() }
        group.addTask { await self.fetchWorkouts() }
    }
}

The fetchAll() method uses a TaskGroup so all four queries run concurrently — HealthKit handles them in parallel, and the UI gets updated as each one completes. You’ll add fetchWorkouts() in Step 8.

Step 4: Building the Daily Steps Ring View

The centerpiece of the dashboard is a progress ring showing today’s steps as a fraction of the 10,000-step goal — just the kind of display Mr. Incredible would demand for his training regime. This ring uses SwiftUI’s Circle with the .trim(from:to:) modifier, which clips the stroke to a fraction of the circumference.

Create Views/StepsRingView.swift:

import SwiftUI

struct StepsRingView: View {
    let steps: Double
    let goal: Double = 10_000

    private var progress: Double {
        min(steps / goal, 1.0) // clamp at 100%
    }

    var body: some View {
        ZStack {
            // Background track
            Circle()
                .stroke(Color(.systemGray5), lineWidth: 18)

            // Progress arc — trimmed from the top (rotated -90°)
            Circle()
                .trim(from: 0, to: progress)
                .stroke(
                    AngularGradient(
                        gradient: Gradient(colors: [.red, .orange]),
                        center: .center
                    ),
                    style: StrokeStyle(lineWidth: 18, lineCap: .round)
                )
                .rotationEffect(.degrees(-90))
                .animation(.easeOut(duration: 1.0), value: progress)

            // Center label
            VStack(spacing: 4) {
                Text(steps, format: .number)
                    .font(.system(size: 28, weight: .bold, design: .rounded))
                    .foregroundStyle(.primary)
                Text("of \(Int(goal)) steps")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
        }
        .frame(width: 160, height: 160)
    }
}

The .trim(from: 0, to: progress) modifier clips the circle’s stroke. Because Circle starts drawing at the 3 o’clock position by default, we apply .rotationEffect(.degrees(-90)) to start at 12 o’clock instead — which matches the familiar Activity ring appearance on Apple Watch.

Now set up the main dashboard. Open Views/DashboardView.swift:

import SwiftUI

struct DashboardView: View {
    @Environment(HealthKitManager.self) private var hkManager

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 24) {
                    // Today's stats header
                    VStack(spacing: 8) {
                        Text("Today's Activity")
                            .font(.title2.bold())
                        Text("The Incredible Family Tracker")
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                    }
                    .padding(.top)

                    // Steps ring
                    StepsRingView(steps: hkManager.todaySteps)

                    // Active energy callout
                    HStack {
                        Image(systemName: "flame.fill")
                            .foregroundStyle(.orange)
                        Text("\(Int(hkManager.activeEnergyToday)) kcal active energy")
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                    }

                    Divider()

                    // Charts placeholder (built in steps 5 and 6)
                    WeeklyChartView()
                    HeartRateChartView()
                    MonthlyBreakdownView()
                    WorkoutLogView()
                }
                .padding(.horizontal)
            }
            .navigationTitle("Incredible Fitness")
            .task {
                await hkManager.fetchAll()
            }
            .refreshable {
                await hkManager.fetchAll()
            }
        }
    }
}

Update ContentView.swift to show the dashboard:

import SwiftUI

struct ContentView: View {
    var body: some View {
        DashboardView()
    }
}

Checkpoint: Build and run. The dashboard should load with the steps ring in the center displaying today’s step count (or 0 if the simulator has no data). The progress arc animates in from 0 to the current fraction when the view appears. Pull to refresh re-queries all health data.

Step 5: Charting Weekly Steps with Swift Charts

Swift Charts is a declarative charting framework introduced at WWDC 2022. You describe your chart using mark types — BarMark, LineMark, PointMark, etc. — and the framework handles rendering, axes, legends, and accessibility automatically.

Create Views/WeeklyChartView.swift. This view will render a bar chart showing the past seven days of step counts, with a horizontal rule at the 10,000-step goal line:

import SwiftUI
import Charts

struct WeeklyChartView: View {
    @Environment(HealthKitManager.self) private var hkManager

    private let goal: Double = 10_000

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Weekly Steps")
                .font(.headline)

            Chart {
                // Goal line — drawn before the bars so it renders behind them
                RuleMark(y: .value("Goal", goal))
                    .lineStyle(StrokeStyle(lineWidth: 1, dash: [4]))
                    .foregroundStyle(.secondary)
                    .annotation(position: .top, alignment: .leading) {
                        Text("Goal")
                            .font(.caption2)
                            .foregroundStyle(.secondary)
                    }

                ForEach(hkManager.weeklySteps) { day in
                    BarMark(
                        x: .value("Day", day.date, unit: .day),
                        y: .value("Steps", day.count)
                    )
                    .foregroundStyle(day.count >= goal ? Color.green : Color.red)
                    .cornerRadius(4)
                }
            }
            .chartXAxis {
                AxisMarks(values: .stride(by: .day)) { value in
                    AxisGridLine()
                    AxisValueLabel(format: .dateTime.weekday(.abbreviated))
                }
            }
            .chartYAxis {
                AxisMarks(position: .leading)
            }
            .frame(height: 200)
        }
        .padding()
        .background(Color(.secondarySystemBackground))
        .clipShape(RoundedRectangle(cornerRadius: 12))
    }
}

There are a few chart-specific patterns to understand here. The Chart view is the container — everything inside its closure describes the marks to render. RuleMark(y:) draws a horizontal line at a constant y-value, which we use for the goal threshold. BarMark(x:y:) plots the bars; we use .value("Day", day.date, unit: .day) so the x-axis automatically groups by calendar day. The conditional .foregroundStyle colors bars green if the goal was met and red otherwise — Violet hit her goal, Dash always does, Mr. Incredible had a lazy Sunday.

Tip: Swift Charts automatically infers axis scales from the data range. If you want to force the y-axis to always start at 0 (rather than the minimum value in the dataset), add .chartYScale(domain: 0...maxValue) to the Chart view.

Step 6: Adding a Heart Rate Line Chart

Heart rate data tells a more nuanced story than step counts — it reveals workout intensity and resting recovery. A LineMark with PointMark overlaid is the right visualization here: the line shows the trend, and individual dots reveal the density of readings.

Create Views/HeartRateChartView.swift:

import SwiftUI
import Charts

struct HeartRateChartView: View {
    @Environment(HealthKitManager.self) private var hkManager

    // Compute average for a reference line
    private var averageBPM: Double {
        guard !hkManager.heartRateSamples.isEmpty else { return 0 }
        let total = hkManager.heartRateSamples.reduce(0) { $0 + $1.beatsPerMinute }
        return total / Double(hkManager.heartRateSamples.count)
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                Text("Heart Rate (7 days)")
                    .font(.headline)
                Spacer()
                if averageBPM > 0 {
                    Label("\(Int(averageBPM)) avg bpm", systemImage: "heart.fill")
                        .font(.caption)
                        .foregroundStyle(.red)
                }
            }

            if hkManager.heartRateSamples.isEmpty {
                Text("No heart rate data available.\nAdd data in the Health app.")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
                    .multilineTextAlignment(.center)
                    .frame(maxWidth: .infinity)
                    .frame(height: 180)
            } else {
                Chart {
                    // Average reference line
                    if averageBPM > 0 {
                        RuleMark(y: .value("Average", averageBPM))
                            .lineStyle(StrokeStyle(lineWidth: 1, dash: [4]))
                            .foregroundStyle(.red.opacity(0.5))
                    }

                    // Line connecting samples
                    ForEach(hkManager.heartRateSamples) { sample in
                        LineMark(
                            x: .value("Time", sample.date),
                            y: .value("BPM", sample.beatsPerMinute)
                        )
                        .foregroundStyle(.red)
                        .interpolationMethod(.catmullRom) // smooth curve
                    }

                    // Individual data points
                    ForEach(hkManager.heartRateSamples) { sample in
                        PointMark(
                            x: .value("Time", sample.date),
                            y: .value("BPM", sample.beatsPerMinute)
                        )
                        .foregroundStyle(.red)
                        .symbolSize(20)
                    }
                }
                .chartXAxis {
                    AxisMarks(values: .stride(by: .day)) { _ in
                        AxisGridLine()
                        AxisValueLabel(format: .dateTime.month(.abbreviated).day())
                    }
                }
                .chartYScale(domain: 40...200) // typical resting-to-max range
                .frame(height: 180)
            }
        }
        .padding()
        .background(Color(.secondarySystemBackground))
        .clipShape(RoundedRectangle(cornerRadius: 12))
    }
}

The .interpolationMethod(.catmullRom) call smooths the line between data points using a Catmull-Rom spline. This makes the chart read more like a continuous trend rather than a jagged series of connected dots — appropriate for heart rate data where you expect smooth biological variation.

Checkpoint: Build and run. The Weekly Steps chart should show colored bars for the past seven days, and the Heart Rate chart should show a smooth red line. If you’re on the simulator with manually added data, both charts should be populated. If you’re on a physical device with Health data, you’ll see your real readings.

Step 7: Monthly Activity Breakdown with SectorMark

For a high-level overview of how the family splits its activity across types — steps, active energy, and dedicated workouts — a pie chart communicates proportions at a glance. Swift Charts provides SectorMark for this.

Create Views/MonthlyBreakdownView.swift. This view computes percentage breakdowns from the data already fetched:

import SwiftUI
import Charts

struct ActivitySlice: Identifiable {
    let id = UUID()
    let label: String
    let value: Double
    let color: Color
}

struct MonthlyBreakdownView: View {
    @Environment(HealthKitManager.self) private var hkManager

    private var slices: [ActivitySlice] {
        let totalSteps = hkManager.weeklySteps.reduce(0) { $0 + $1.count }
        let totalCalories = hkManager.activeEnergyToday   // simplified: today only
        let workoutMinutes = hkManager.workouts.reduce(0) { $0 + $1.durationMinutes }

        // Normalize to a common unit so the chart is meaningful
        // We treat 1000 steps ≈ 1 unit, 100 kcal ≈ 1 unit, 10 min workout ≈ 1 unit
        let stepsUnit = totalSteps / 1000
        let calsUnit = totalCalories / 100
        let workoutUnit = workoutMinutes / 10

        let total = stepsUnit + calsUnit + workoutUnit
        guard total > 0 else { return [] }

        return [
            ActivitySlice(label: "Steps", value: stepsUnit / total, color: .blue),
            ActivitySlice(label: "Active Energy", value: calsUnit / total, color: .orange),
            ActivitySlice(label: "Workouts", value: workoutUnit / total, color: .green)
        ]
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Activity Mix")
                .font(.headline)

            if slices.isEmpty {
                Text("No activity data yet.")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
                    .frame(maxWidth: .infinity)
                    .frame(height: 200)
            } else {
                HStack(alignment: .center, spacing: 20) {
                    Chart(slices) { slice in
                        SectorMark(
                            angle: .value("Share", slice.value),
                            innerRadius: .ratio(0.55), // donut hole
                            angularInset: 2            // gap between sectors
                        )
                        .foregroundStyle(slice.color)
                        .cornerRadius(4)
                    }
                    .frame(width: 150, height: 150)

                    // Legend
                    VStack(alignment: .leading, spacing: 8) {
                        ForEach(slices) { slice in
                            HStack(spacing: 8) {
                                RoundedRectangle(cornerRadius: 3)
                                    .fill(slice.color)
                                    .frame(width: 12, height: 12)
                                Text(slice.label)
                                    .font(.caption)
                                Spacer()
                                Text("\(Int(slice.value * 100))%")
                                    .font(.caption.monospacedDigit())
                                    .foregroundStyle(.secondary)
                            }
                        }
                    }
                }
            }
        }
        .padding()
        .background(Color(.secondarySystemBackground))
        .clipShape(RoundedRectangle(cornerRadius: 12))
    }
}

SectorMark takes an angle parameter that represents how large each sector should be — Swift Charts computes the actual angles from the proportional values automatically. The innerRadius: .ratio(0.55) parameter turns it into a donut chart, which is generally more readable than a fully filled pie when you have a legend alongside it. The angularInset: 2 adds a small visual gap between slices.

Tip: SectorMark was introduced in iOS 17. Since our deployment target is iOS 18, we can use it freely. If you needed to support iOS 16, you’d implement the pie chart manually using Path — see the SwiftUI Custom Shapes post for that approach.

Step 8: Querying the Workout Log

The final piece is a list of recorded workouts — Mrs. Incredible’s yoga sessions, Dash’s track workouts, Mr. Incredible’s strength training. HealthKit stores workouts as HKWorkout objects, which carry duration, calorie count, distance, and a workout type enum.

Add fetchWorkouts() to HealthKitManager.swift:

func fetchWorkouts(limit: Int = 20) async {
    let workoutType = HKObjectType.workoutType()
    let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
    let predicate = HKQuery.predicateForSamples(
        withStart: Calendar.current.date(byAdding: .month, value: -1, to: Date()),
        end: Date(),
        options: .strictStartDate
    )

    let records: [WorkoutRecord] = await withCheckedContinuation { continuation in
        let query = HKSampleQuery(
            sampleType: workoutType,
            predicate: predicate,
            limit: limit,
            sortDescriptors: [sortDescriptor]
        ) { _, results, _ in
            let workoutSamples = (results as? [HKWorkout]) ?? []
            let mapped: [WorkoutRecord] = workoutSamples.map { workout in
                let durationMins = workout.duration / 60
                let calories = workout.statistics(for: HKQuantityType(.activeEnergyBurned))?
                    .sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0
                let distanceKm = workout.statistics(for: HKQuantityType(.distanceWalkingRunning))?
                    .sumQuantity()?.doubleValue(for: .meterUnit(with: .kilo))

                return WorkoutRecord(
                    date: workout.startDate,
                    activityType: workout.workoutActivityType.name,
                    durationMinutes: durationMins,
                    caloriesBurned: calories,
                    distanceKilometers: distanceKm
                )
            }
            continuation.resume(returning: mapped)
        }
        store.execute(query)
    }

    workouts = records
}

HKWorkout stores associated statistics — calories, distance — via statistics(for:). Note that distance is only present for activity types that track movement (running, cycling, walking); strength training workouts won’t have a distance value, which is why distanceKilometers is optional.

The workout.workoutActivityType.name property doesn’t exist on HKWorkoutActivityType by default. Add this extension at the bottom of FitnessModels.swift to give each type a human-readable name:

import HealthKit

extension HKWorkoutActivityType {
    var name: String {
        switch self {
        case .running:          return "Running"
        case .cycling:          return "Cycling"
        case .swimming:         return "Swimming"
        case .walking:          return "Walking"
        case .functionalStrengthTraining: return "Strength Training"
        case .yoga:             return "Yoga"
        case .highIntensityIntervalTraining: return "HIIT"
        default:                return "Workout"
        }
    }
}

Create Views/WorkoutLogView.swift to display the list:

import SwiftUI

struct WorkoutLogView: View {
    @Environment(HealthKitManager.self) private var hkManager

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Recent Workouts")
                .font(.headline)

            if hkManager.workouts.isEmpty {
                Text("No workouts in the past month.\nTime to suit up!")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
                    .multilineTextAlignment(.center)
                    .frame(maxWidth: .infinity)
                    .padding(.vertical, 20)
            } else {
                ForEach(hkManager.workouts) { workout in
                    WorkoutRowView(workout: workout)
                    Divider()
                }
            }
        }
        .padding()
        .background(Color(.secondarySystemBackground))
        .clipShape(RoundedRectangle(cornerRadius: 12))
    }
}

private struct WorkoutRowView: View {
    let workout: WorkoutRecord

    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 4) {
                Text(workout.activityType)
                    .font(.subheadline.bold())
                Text(workout.date, format: .dateTime.month().day().hour().minute())
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }

            Spacer()

            VStack(alignment: .trailing, spacing: 4) {
                Text("\(Int(workout.durationMinutes)) min")
                    .font(.subheadline.monospacedDigit())
                HStack(spacing: 4) {
                    Text("\(Int(workout.caloriesBurned)) kcal")
                    if let dist = workout.distanceKilometers {
                        Text(\(dist, specifier: "%.1f") km")
                    }
                }
                .font(.caption)
                .foregroundStyle(.secondary)
            }
        }
        .padding(.vertical, 4)
    }
}

Checkpoint: Build and run the complete app. You should see all four sections of the dashboard: the steps ring at the top, the weekly bar chart, the heart rate line chart, the activity mix donut chart, and the workout log at the bottom. Pull down to refresh all data. If you’re on a physical device with recorded workouts, they’ll appear in the log. On the simulator, add workout data through the Health app’s built-in simulator support or seed it programmatically via HKWorkoutBuilder.

Where to Go From Here?

Congratulations! You’ve built The Incredibles Family Fitness Tracker — a HealthKit-powered SwiftUI dashboard with four distinct visualizations, async data fetching, and a clean manager architecture.

Here’s what you learned:

  • How to add the HealthKit entitlement and privacy keys required by App Store review
  • How to request health data authorization using HKHealthStore.requestAuthorization(toShare:read:)
  • How to bridge HealthKit’s callback-based HKStatisticsCollectionQuery and HKSampleQuery to async/await using withCheckedContinuation
  • How to compose BarMark, LineMark, PointMark, and SectorMark in Swift Charts to tell different stories with the same data
  • How to build an animated progress ring using Circle().trim(from:to:) with AngularGradient
  • How to query HKWorkout records and extract per-workout statistics for calories and distance

Ideas for extending this project:

  • Apple Watch complication — use WidgetKit and CLKComplicationDataSource to push the steps ring to the watch face so Mr. Incredible can check his progress without reaching for his phone.
  • Background delivery — call HKHealthStore.enableBackgroundDelivery(for:frequency:withCompletion:) so the app updates its data even when it’s not in the foreground. This is how the Activity app keeps rings in sync.
  • Workout recording — use HKWorkoutBuilder to let users start, pause, and stop workout sessions directly from the app, writing data back to HealthKit.
  • Family sharing — explore HKDocumentQuery and SharePlay to let the Parr family compare their stats in a shared session.
  • Trend analysis — use HKStatisticsQuery with .discreteAverage to compute resting heart rate trends over 30 or 90 days, then highlight improvements with conditional chart annotations.