How to Build a Blood Sugar Tracker App in SwiftUI

A blood sugar tracker lets people with diabetes or prediabetes log glucose readings, spot trends over time, and sync data with Apple Health — all from their pocket. This guide is for iOS developers who want to ship a privacy-first, App Store–ready glucose logging app using SwiftData, HealthKit, and Swift Charts.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Intermediate Estimated time: 1 week Updated: May 12, 2026

Prerequisites

Architecture overview

The app uses a two-layer persistence strategy: SwiftData stores every reading locally in a private SQLite store (fast, offline-first), while HealthKit acts as a secondary sync target so data surfaces in the system Health app. A single @Observable GlucoseStore owns both the SwiftData model context and the HKHealthStore, acting as the single source of truth for every view. Swift Charts consumes the store's query results directly — no intermediate transform layer required.

BloodSugarTracker/
├── BloodSugarTrackerApp.swift        # App entry · .modelContainer · inject HealthKitManager
├── Models/
│   └── GlucoseReading.swift          # @Model: value, unit, mealContext, date, note
├── Views/
│   ├── ContentView.swift             # TabView container (Dashboard · History · Chart)
│   ├── DashboardView.swift           # Latest reading card + quick-log button
│   ├── LogReadingView.swift          # Sheet: numeric entry + meal picker + save
│   ├── HistoryView.swift             # Paginated @Query list of all readings
│   └── ChartView.swift               # Swift Charts line chart + range picker
├── Stores/
│   └── HealthKitManager.swift        # @Observable · auth · HKQuantitySample write
├── PrivacyInfo.xcprivacy             # Required since iOS 17.2 for App Store
└── Info.plist                        # NSHealthShareUsageDescription + Update key

Step-by-step

1. Project setup and capabilities

Create a new SwiftUI project targeting iOS 17 in Xcode 16. In the Signing & Capabilities tab, add the HealthKit and In-App Purchase capabilities. HealthKit requires the entitlement even if you only write data — without it, every HKHealthStore call silently fails at runtime with no error message. Inject HealthKitManager as an environment value from the app root so all views share the same authorized store instance.

// BloodSugarTrackerApp.swift
import SwiftUI
import SwiftData

@main
struct BloodSugarTrackerApp: App {
    @State private var hkManager = HealthKitManager()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(hkManager)
                .task {
                    await hkManager.requestAuthorization()
                }
        }
        .modelContainer(for: GlucoseReading.self)
    }
}

2. Data model with SwiftData

Define the GlucoseReading model with all the fields you'll filter and chart. Store unit and meal context as raw String values — this keeps the model Codable without a custom transformer and sidesteps a SwiftData migration if you rename an enum case later.

// Models/GlucoseReading.swift
import SwiftData
import Foundation

enum MealContext: String, Codable, CaseIterable {
    case fasting    = "Fasting"
    case beforeMeal = "Before Meal"
    case afterMeal  = "After Meal"
    case bedtime    = "Bedtime"
    case other      = "Other"
}

enum GlucoseUnit: String, Codable, CaseIterable {
    case mgdL  = "mg/dL"
    case mmolL = "mmol/L"

    func convert(_ value: Double, to target: GlucoseUnit) -> Double {
        guard self != target else { return value }
        return self == .mgdL ? value / 18.0 : value * 18.0
    }
}

@Model
final class GlucoseReading {
    var value: Double
    var unit: String         // raw GlucoseUnit.rawValue
    var mealContext: String  // raw MealContext.rawValue
    var date: Date
    var note: String

    init(
        value: Double,
        unit: GlucoseUnit = .mgdL,
        mealContext: MealContext = .other,
        date: Date = .now,
        note: String = ""
    ) {
        self.value       = value
        self.unit        = unit.rawValue
        self.mealContext = mealContext.rawValue
        self.date        = date
        self.note        = note
    }
}

3. Dashboard UI

The dashboard surfaces the latest reading with a color-coded indicator (green inside 70–180 mg/dL, red below, orange above) and puts the log button one tap away. Users check glucose multiple times a day — every unnecessary tap is friction that leads to skipped entries and useless trend data.

// Views/DashboardView.swift
import SwiftUI
import SwiftData

struct DashboardView: View {
    @Query(sort: \GlucoseReading.date, order: .reverse)
    private var readings: [GlucoseReading]

    @State private var showingLog = false

    private var latest: GlucoseReading? { readings.first }

    private var statusColor: Color {
        guard let r = latest else { return .gray }
        switch r.value {
        case .<70:    return .red
        case 70...180: return .green
        default:       return .orange
        }
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 24) {
                VStack(spacing: 8) {
                    Text("Latest Reading")
                        .font(.subheadline)
                        .foregroundStyle(.secondary)

                    if let r = latest {
                        HStack(alignment: .firstTextBaseline, spacing: 4) {
                            Text(r.value,
                                 format: .number.precision(.fractionLength(0...1)))
                                .font(.system(size: 64, weight: .bold, design: .rounded))
                                .foregroundStyle(statusColor)
                            Text(r.unit)
                                .font(.title3)
                                .foregroundStyle(.secondary)
                        }
                        Text(r.date.formatted(date: .abbreviated, time: .shortened))
                            .font(.caption)
                            .foregroundStyle(.secondary)
                        Text(r.mealContext)
                            .font(.caption)
                            .padding(.horizontal, 8)
                            .padding(.vertical, 2)
                            .background(.thinMaterial)
                            .clipShape(Capsule())
                    } else {
                        Text("No readings yet")
                            .font(.title2)
                            .foregroundStyle(.secondary)
                    }
                }
                .frame(maxWidth: .infinity)
                .padding(24)
                .background(.regularMaterial)
                .clipShape(RoundedRectangle(cornerRadius: 20))

                Button {
                    showingLog = true
                } label: {
                    Label("Log Reading", systemImage: "drop.fill")
                        .font(.headline)
                        .frame(maxWidth: .infinity)
                        .padding()
                        .background(Color.accentColor)
                        .foregroundStyle(.white)
                        .clipShape(RoundedRectangle(cornerRadius: 14))
                }

                Spacer()
            }
            .padding()
            .navigationTitle("Blood Sugar")
            .sheet(isPresented: $showingLog) {
                LogReadingView()
            }
        }
    }
}

#Preview {
    DashboardView()
        .modelContainer(for: GlucoseReading.self, inMemory: true)
}

4. Glucose logging (core feature)

The log sheet is the most-used screen in the app. Validate input range before saving — values outside 20–600 mg/dL or 1.1–33.3 mmol/L are almost certainly user error, and writing garbage to HealthKit corrupts the user's Health history. Save to SwiftData synchronously, then kick off the HealthKit write in a background Task so the sheet dismisses instantly.

// Views/LogReadingView.swift
import SwiftUI
import SwiftData

struct LogReadingView: View {
    @Environment(\.modelContext) private var context
    @Environment(\.dismiss) private var dismiss
    @Environment(HealthKitManager.self) private var hkManager

    @State private var valueText   = ""
    @State private var unit        = GlucoseUnit.mgdL
    @State private var mealContext = MealContext.other
    @State private var date        = Date.now
    @State private var note        = ""
    @State private var errorMessage: String?

    private var parsedValue: Double? {
        Double(valueText.replacingOccurrences(of: ",", with: "."))
    }

    private var isInRange: Bool {
        guard let v = parsedValue else { return false }
        let range: ClosedRange<Double> = unit == .mgdL ? 20...600 : 1.1...33.3
        return range.contains(v)
    }

    var body: some View {
        NavigationStack {
            Form {
                Section("Glucose Value") {
                    HStack {
                        TextField("e.g. 110", text: $valueText)
                            .keyboardType(.decimalPad)
                        Picker("Unit", selection: $unit) {
                            ForEach(GlucoseUnit.allCases, id: \.self) {
                                Text($0.rawValue)
                            }
                        }
                        .pickerStyle(.segmented)
                        .fixedSize()
                    }
                    if let err = errorMessage {
                        Text(err)
                            .font(.caption)
                            .foregroundStyle(.red)
                    }
                }

                Section("Context") {
                    Picker("Meal Context", selection: $mealContext) {
                        ForEach(MealContext.allCases, id: \.self) {
                            Text($0.rawValue).tag($0)
                        }
                    }
                    DatePicker("Date & Time", selection: $date,
                               displayedComponents: [.date, .hourAndMinute])
                }

                Section("Note (optional)") {
                    TextField("Add a note…", text: $note, axis: .vertical)
                        .lineLimit(3...6)
                }
            }
            .navigationTitle("Log Reading")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") { save() }
                        .disabled(parsedValue == nil)
                }
            }
        }
    }

    private func save() {
        guard let v = parsedValue else { return }
        guard isInRange else {
            errorMessage = unit == .mgdL
                ? "Value must be between 20 and 600 mg/dL"
                : "Value must be between 1.1 and 33.3 mmol/L"
            return
        }
        let reading = GlucoseReading(
            value: v, unit: unit,
            mealContext: mealContext,
            date: date, note: note
        )
        context.insert(reading)
        Task {
            await hkManager.write(value: v, unit: unit, date: date)
        }
        dismiss()
    }
}

#Preview {
    LogReadingView()
        .modelContainer(for: GlucoseReading.self, inMemory: true)
        .environment(HealthKitManager())
}

5. HealthKit integration

Isolate all HealthKit calls in a dedicated HealthKitManager so the rest of the app stays fully testable. Always guard with HKHealthStore.isHealthDataAvailable() before any call — iPad models without HealthKit exist, and calling the API on them crashes immediately.

// Stores/HealthKitManager.swift
import HealthKit
import Observation

@Observable
final class HealthKitManager {
    private let store      = HKHealthStore()
    private let glucoseType = HKQuantityType(.bloodGlucose)

    private(set) var isAuthorized = false

    func requestAuthorization() async {
        guard HKHealthStore.isHealthDataAvailable() else { return }
        let toShare: Set<HKSampleType> = [glucoseType]
        let toRead:  Set<HKObjectType>  = [glucoseType]
        do {
            try await store.requestAuthorization(toShare: toShare, read: toRead)
            isAuthorized = true
        } catch {
            print("HealthKit auth error:", error)
        }
    }

    func write(value: Double, unit: GlucoseUnit, date: Date) async {
        guard HKHealthStore.isHealthDataAvailable(), isAuthorized else { return }

        // Use the correct molar-mass constant — do NOT use a string literal here
        let hkUnit: HKUnit = unit == .mgdL
            ? HKUnit(from: "mg/dL")
            : HKUnit.moleUnit(
                with: .milli,
                molarMass: HKUnitMolarMassBloodGlucose   // 180.0 g/mol
              )

        let quantity = HKQuantity(unit: hkUnit, doubleValue: value)
        let sample   = HKQuantitySample(
            type: glucoseType,
            quantity: quantity,
            start: date, end: date
        )
        do {
            try await store.save(sample)
        } catch {
            print("HealthKit write error:", error)
        }
    }
}

6. Charts visualization

Overlay a RectangleMark shading the 70–180 mg/dL normal range and dashed RuleMarks at each threshold. This is the single most valuable visual for diabetes self-management — users immediately see which readings fall outside range without reading numbers.

// Views/ChartView.swift
import SwiftUI
import SwiftData
import Charts

enum TimeRange: String, CaseIterable {
    case week  = "7 Days"
    case month = "30 Days"
}

struct ChartView: View {
    @Query(sort: \GlucoseReading.date, order: .forward)
    private var allReadings: [GlucoseReading]

    @State private var range = TimeRange.week

    private var filtered: [GlucoseReading] {
        let days   = range == .week ? -7 : -30
        let cutoff = Calendar.current.date(byAdding: .day, value: days, to: .now)!
        return allReadings.filter { $0.date >= cutoff }
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 16) {
                Picker("Range", selection: $range) {
                    ForEach(TimeRange.allCases, id: \.self) { Text($0.rawValue) }
                }
                .pickerStyle(.segmented)
                .padding(.horizontal)

                if filtered.isEmpty {
                    ContentUnavailableView(
                        "No Data",
                        systemImage: "chart.xyaxis.line",
                        description: Text("Log some readings to see your trend.")
                    )
                } else {
                    Chart {
                        // Normal-range background band
                        RectangleMark(
                            yStart: .value("Low",  70),
                            yEnd:   .value("High", 180)
                        )
                        .foregroundStyle(Color.green.opacity(0.07))

                        // Low threshold rule
                        RuleMark(y: .value("Low", 70))
                            .foregroundStyle(.red.opacity(0.45))
                            .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4]))
                            .annotation(position: .trailing) {
                                Text("70").font(.caption2).foregroundStyle(.red)
                            }

                        // High threshold rule
                        RuleMark(y: .value("High", 180))
                            .foregroundStyle(.orange.opacity(0.55))
                            .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4]))
                            .annotation(position: .trailing) {
                                Text("180").font(.caption2).foregroundStyle(.orange)
                            }

                        // Glucose readings line + points
                        ForEach(filtered, id: \.persistentModelID) { r in
                            LineMark(
                                x: .value("Date",    r.date),
                                y: .value("Glucose", r.value)
                            )
                            .interpolationMethod(.catmullRom)
                            .foregroundStyle(Color.accentColor)

                            PointMark(
                                x: .value("Date",    r.date),
                                y: .value("Glucose", r.value)
                            )
                            .foregroundStyle(Color.accentColor)
                            .symbolSize(30)
                        }
                    }
                    .chartYScale(domain: 40...320)
                    .chartXAxis {
                        AxisMarks(values: .automatic(desiredCount: 5)) { _ in
                            AxisGridLine()
                            AxisValueLabel(
                                format: .dateTime.month(.abbreviated).day()
                            )
                        }
                    }
                    .frame(height: 260)
                    .padding()
                }
                Spacer()
            }
            .navigationTitle("Trends")
        }
    }
}

#Preview {
    ChartView()
        .modelContainer(for: GlucoseReading.self, inMemory: true)
}

7. Privacy Manifest

Since iOS 17.2, every app using required-reason APIs or collecting health data must ship a PrivacyInfo.xcprivacy file. Apps without it are rejected at App Store review — not upload. Add it via File → New → Privacy Manifest in Xcode, then also add the HealthKit usage strings to Info.plist.

<!-- PrivacyInfo.xcprivacy -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
    "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>NSPrivacyTracking</key>
  <false/>
  <key>NSPrivacyTrackingDomains</key>
  <array/>

  <key>NSPrivacyCollectedDataTypes</key>
  <array>
    <dict>
      <key>NSPrivacyCollectedDataType</key>
      <string>NSPrivacyCollectedDataTypeHealthAndFitness</string>
      <key>NSPrivacyCollectedDataTypeLinked</key>
      <false/>
      <key>NSPrivacyCollectedDataTypeTracking</key>
      <false/>
      <key>NSPrivacyCollectedDataTypePurposes</key>
      <array>
        <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
      </array>
    </dict>
  </array>

  <key>NSPrivacyAccessedAPITypes</key>
  <array>
    <dict>
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array>
        <string>CA92.1</string>
      </array>
    </dict>
  </array>
</dict>
</plist>

<!-- Also required in Info.plist: -->
<key>NSHealthShareUsageDescription</key>
<string>Blood Sugar Tracker reads your glucose history from Apple Health.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>Blood Sugar Tracker saves glucose readings to Apple Health so they appear in your Health dashboard.</string>

Common pitfalls

Adding monetization: Subscription

Use StoreKit 2's Product.products(for:) to load your subscription SKUs and Transaction.currentEntitlements to verify active access on every app launch. Gate premium features — the 30-day trend chart, CSV export, and estimated average glucose calculations — behind a subscription check in your @Observable GlucoseStore. Create two subscription groups in App Store Connect: monthly ($2.99) and annual ($19.99) with a 7-day free trial. Present the paywall sheet contextually when the user tries to access a locked feature rather than from a Settings screen — contextual paywalls convert significantly better because the user has already demonstrated intent. Use SubscriptionStoreView (StoreKit 2, iOS 17+) to render the paywall UI in one line; App Store handles localization, currency formatting, and introductory offer display automatically.

Shipping this faster with Soarias

Soarias automates the parts of this build that have nothing to do with glucose logging. It scaffolds the full SwiftData + HealthKit project structure — including a correctly populated PrivacyInfo.xcprivacy with health data entries, both NSHealthShareUsageDescription and NSHealthUpdateUsageDescription in Info.plist, the HealthKit entitlement wired to your bundle ID, and the StoreKit configuration file for your subscription products — so you skip the two-hour Xcode setup and go straight to writing LogReadingView. The built-in fastlane integration generates App Store screenshots across all required device sizes and submits the binary to App Store Connect, including attaching your subscription IAPs for review.

For an intermediate app like this — HealthKit sync, Swift Charts, StoreKit subscriptions — a solo developer typically spends three to four days on infrastructure setup before writing a single line of business logic. With Soarias, that scaffolding takes under ten minutes. That leaves the full week to focus on the glucose logging UX, the A1c estimate algorithm, and the paywall flow that actually determines whether the app generates revenue.

Related guides

FAQ

Does this work on iOS 16?

SwiftData and ContentUnavailableView require iOS 17+. You could replace SwiftData with Core Data and the unavailable view with a custom empty state to target iOS 16, but given that the vast majority of active devices are on iOS 17 or later, the backport rarely justifies the added complexity and maintenance surface.

Do I need a paid Apple Developer account to test?

A free account lets you sideload directly from Xcode to your own device for development. You need a paid account ($99/year) to enable the HealthKit entitlement on a provisioned distribution profile, upload to TestFlight, and submit to the App Store. HealthKit specifically requires the paid entitlement — it cannot be tested outside Xcode's direct-install flow with a free account.

How do I add this to the App Store?

Create an app record and configure your StoreKit subscription products in App Store Connect, archive the build in Xcode, then upload via Xcode Organizer. Health apps must include a medical disclaimer in the App Store description and within the app itself. Plan for 3–5 business days on the first review cycle — health category apps are frequently manually reviewed and may receive follow-up questions about your data handling practices.

Can I automatically import data from a Continuous Glucose Monitor (CGM)?

Yes — CGM devices like Dexcom and Libre write readings directly to the Health app, and you can subscribe to those updates using HKObserverQuery with background delivery. This requires a separate HealthKit background delivery entitlement and Apple may request documentation of how your app handles a continuous stream of sensitive health data during review. For a first version, reading existing HealthKit samples on app launch provides solid value with far less review complexity; treat background CGM import as a v2 milestone.

Last reviewed: 2026-05-12 by the Soarias team.