How to Build a Sleep Tracker App in SwiftUI

A Sleep Tracker app lets users log bedtime, wake time, and sleep quality — then visualizes trends over days and weeks via Swift Charts and Apple Health. It's ideal for developers who want to build a health-adjacent app with real persistence and a defensible subscription monetization model.

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

Prerequisites

Architecture overview

The app uses a three-layer design: SwiftData handles local persistence of SleepEntry records so the app works offline first; HealthKitManager (an @Observable class) syncs each logged session to Apple Health as a sleep analysis sample; and SubscriptionManager wraps StoreKit 2 to gate premium features like the 14-night trend chart and CSV export. Views are driven entirely by @Query from SwiftData — no manual refresh needed.

SleepTracker/
├── SleepTrackerApp.swift          # ModelContainer setup, @main
├── Models/
│   └── SleepEntry.swift           # @Model: bedtime, wakeTime, quality
├── Views/
│   ├── ContentView.swift          # Root NavigationStack
│   ├── SleepLogView.swift         # List + inline SleepChartView
│   ├── LogSleepView.swift         # Sheet: DatePicker + quality rating
│   ├── SleepChartView.swift       # Swift Charts bar chart
│   └── PaywallView.swift          # StoreKit 2 subscription paywall
├── Managers/
│   ├── HealthKitManager.swift     # @Observable, HKHealthStore
│   └── SubscriptionManager.swift  # @Observable, StoreKit 2
└── PrivacyInfo.xcprivacy          # Required for App Store

Step-by-step

1. Project setup and HealthKit entitlements

Create a new iOS App target in Xcode 16 with SwiftUI and SwiftData checked. Then go to Signing & Capabilities → + Capability and add HealthKit. Without this entitlement, HKHealthStore will silently refuse all authorization requests — a common confusion.

// SleepTrackerApp.swift
import SwiftUI
import SwiftData

@main
struct SleepTrackerApp: App {
    @State private var healthKit = HealthKitManager()
    @State private var subscriptions = SubscriptionManager()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(healthKit)
                .environment(subscriptions)
                .task {
                    await healthKit.requestAuthorization()
                    await subscriptions.loadProducts()
                }
        }
        .modelContainer(for: SleepEntry.self)
    }
}

2. SwiftData model for sleep entries

The SleepEntry model stores everything needed for display and chart queries. Computed properties for duration live on the model so views stay clean. @Model automatically synthesizes Codable, Identifiable, and change observation.

// Models/SleepEntry.swift
import SwiftData
import Foundation

@Model
final class SleepEntry {
    var bedtime: Date
    var wakeTime: Date
    /// 1 (poor) – 5 (excellent)
    var quality: Int
    var notes: String
    /// True once synced to Apple Health — prevents duplicate writes
    var syncedToHealth: Bool

    var durationHours: Double {
        max(0, wakeTime.timeIntervalSince(bedtime) / 3600)
    }

    var formattedDuration: String {
        let h = Int(durationHours)
        let m = Int((durationHours - Double(h)) * 60)
        return m > 0 ? "\(h)h \(m)m" : "\(h)h"
    }

    init(
        bedtime: Date,
        wakeTime: Date,
        quality: Int,
        notes: String = "",
        syncedToHealth: Bool = false
    ) {
        self.bedtime = bedtime
        self.wakeTime = wakeTime
        self.quality = quality
        self.notes = notes
        self.syncedToHealth = syncedToHealth
    }
}

3. Core sleep log UI

@Query fetches entries sorted newest-first directly from SwiftData. Deleting a row removes it from the local store; the HealthKit sample is separate and persists in Apple Health (which is intentional — never silently delete health records).

// Views/SleepLogView.swift
import SwiftUI
import SwiftData

struct SleepLogView: View {
    @Query(sort: \SleepEntry.wakeTime, order: .reverse)
    private var entries: [SleepEntry]

    @Environment(\.modelContext) private var modelContext
    @Environment(SubscriptionManager.self) private var subscriptions
    @State private var showingLog = false

    var body: some View {
        List {
            if !entries.isEmpty {
                Section {
                    SleepChartView()
                        .listRowInsets(.init())
                        .listRowBackground(Color.clear)
                }
            }

            Section("History") {
                ForEach(entries) { entry in
                    SleepEntryRow(entry: entry)
                }
                .onDelete(perform: delete)
            }
        }
        .navigationTitle("Sleep")
        .toolbar {
            ToolbarItem(placement: .primaryAction) {
                Button("Log Sleep", systemImage: "plus.circle.fill") {
                    showingLog = true
                }
            }
        }
        .sheet(isPresented: $showingLog) {
            LogSleepView()
        }
        .overlay {
            if entries.isEmpty {
                ContentUnavailableView(
                    "No sleep logged",
                    systemImage: "moon.zzz",
                    description: Text("Tap + to record your first night.")
                )
            }
        }
    }

    private func delete(at offsets: IndexSet) {
        for index in offsets { modelContext.delete(entries[index]) }
    }
}

struct SleepEntryRow: View {
    let entry: SleepEntry

    private var qualityLabel: String {
        ["", "Poor", "Fair", "Okay", "Good", "Great"][entry.quality]
    }

    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 2) {
                Text(entry.wakeTime, style: .date)
                    .font(.subheadline).fontWeight(.semibold)
                Text("\(entry.bedtime, style: .time) → \(entry.wakeTime, style: .time)")
                    .font(.caption).foregroundStyle(.secondary)
            }
            Spacer()
            VStack(alignment: .trailing, spacing: 2) {
                Text(entry.formattedDuration)
                    .font(.subheadline).fontWeight(.semibold)
                Text(qualityLabel)
                    .font(.caption).foregroundStyle(.secondary)
            }
        }
        .padding(.vertical, 4)
    }
}

#Preview {
    NavigationStack { SleepLogView() }
        .modelContainer(for: SleepEntry.self, inMemory: true)
        .environment(SubscriptionManager())
}

4. Sleep duration and quality logging form

This is the core feature: a focused sheet where users pick bedtime, wake time, and rate their sleep quality with a moon-icon selector. After saving, it inserts into SwiftData and fires the HealthKit write in the background.

// Views/LogSleepView.swift
import SwiftUI
import SwiftData

struct LogSleepView: View {
    @Environment(\.modelContext) private var modelContext
    @Environment(\.dismiss) private var dismiss
    @Environment(HealthKitManager.self) private var healthKit

    @State private var bedtime = Calendar.current.date(
        byAdding: .hour, value: -8, to: .now
    ) ?? .now
    @State private var wakeTime = Date.now
    @State private var quality = 3
    @State private var notes = ""

    private var durationHours: Double {
        max(0, wakeTime.timeIntervalSince(bedtime) / 3600)
    }
    private var isValid: Bool { durationHours >= 0.5 && durationHours <= 24 }

    var body: some View {
        NavigationStack {
            Form {
                Section("Sleep window") {
                    DatePicker("Bedtime", selection: $bedtime)
                    DatePicker("Wake time", selection: $wakeTime)
                    if isValid {
                        LabeledContent("Duration") {
                            Text(String(format: "%.1f hours", durationHours))
                                .foregroundStyle(.secondary)
                        }
                    }
                }

                Section {
                    HStack(spacing: 16) {
                        ForEach(1...5, id: \.self) { star in
                            Button {
                                withAnimation(.spring(duration: 0.2)) { quality = star }
                            } label: {
                                Image(systemName: star <= quality ? "moon.fill" : "moon")
                                    .font(.title2)
                                    .foregroundStyle(star <= quality ? .indigo : .secondary)
                                    .scaleEffect(star == quality ? 1.2 : 1.0)
                            }
                            .buttonStyle(.plain)
                            .accessibilityLabel("Quality \(star) of 5")
                        }
                    }
                    .padding(.vertical, 4)
                } header: {
                    Text("Quality")
                } footer: {
                    Text("1 moon = restless, 5 moons = excellent")
                }

                Section("Notes (optional)") {
                    TextField("How did you feel?", text: $notes, axis: .vertical)
                        .lineLimit(3...5)
                }
            }
            .navigationTitle("Log Sleep")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") { save() }
                        .disabled(!isValid)
                        .fontWeight(.semibold)
                }
            }
        }
    }

    private func save() {
        let entry = SleepEntry(
            bedtime: bedtime,
            wakeTime: wakeTime,
            quality: quality,
            notes: notes
        )
        modelContext.insert(entry)
        Task { await healthKit.writeSleep(entry: entry) }
        dismiss()
    }
}

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

5. HealthKit integration

Use @Observable so the authorization state can drive UI reactively. Write each session as an HKCategoryValueSleepAnalysis.asleepUnspecified sample — the correct value for user-reported sleep that doesn't distinguish sleep stages.

// Managers/HealthKitManager.swift
import HealthKit
import Foundation

@Observable
final class HealthKitManager {
    private let store = HKHealthStore()
    private(set) var isAuthorized = false
    private(set) var authorizationDenied = false

    private var sleepType: HKCategoryType {
        HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!
    }

    func requestAuthorization() async {
        guard HKHealthStore.isHealthDataAvailable() else { return }
        do {
            try await store.requestAuthorization(
                toShare: [sleepType],
                read: [sleepType]
            )
            // HealthKit doesn't surface the granted/denied result;
            // check by attempting a query.
            isAuthorized = true
        } catch {
            authorizationDenied = true
        }
    }

    func writeSleep(entry: SleepEntry) async {
        guard isAuthorized, !entry.syncedToHealth else { return }
        let sample = HKCategorySample(
            type: sleepType,
            value: HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue,
            start: entry.bedtime,
            end: entry.wakeTime,
            metadata: [HKMetadataKeyWasUserEntered: true]
        )
        do {
            try await store.save(sample)
            await MainActor.run { entry.syncedToHealth = true }
        } catch {
            // Non-fatal: the entry is still saved locally in SwiftData
            print("HealthKit write failed: \(error.localizedDescription)")
        }
    }
}

6. Swift Charts trend visualization

A 14-night bar chart gives users an at-a-glance picture of their sleep pattern. Bar color encodes quality (indigo = great, red = poor), and a dashed rule marks the widely recommended 8-hour target. Gate this view behind the subscription paywall for premium users.

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

struct SleepChartView: View {
    @Query(sort: \SleepEntry.wakeTime, order: .reverse)
    private var entries: [SleepEntry]

    private var chartData: [SleepEntry] {
        Array(entries.prefix(14)).reversed()
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("14-Night Trend")
                .font(.headline)
                .padding(.horizontal)

            Chart(chartData) { entry in
                BarMark(
                    x: .value("Date", entry.wakeTime, unit: .day),
                    y: .value("Hours", entry.durationHours)
                )
                .foregroundStyle(colorForQuality(entry.quality).gradient)
                .cornerRadius(4)

                RuleMark(y: .value("Goal", 8.0))
                    .lineStyle(StrokeStyle(lineWidth: 1.5, dash: [5, 3]))
                    .foregroundStyle(.secondary.opacity(0.6))
                    .annotation(position: .trailing, alignment: .center) {
                        Text("8h")
                            .font(.caption2)
                            .foregroundStyle(.secondary)
                    }
            }
            .chartYAxis {
                AxisMarks(values: [4, 6, 8, 10]) { value in
                    AxisGridLine()
                    AxisValueLabel {
                        if let h = value.as(Double.self) {
                            Text("\(Int(h))h").font(.caption2)
                        }
                    }
                }
            }
            .chartXAxis {
                AxisMarks(values: .stride(by: .day, count: 7)) { _ in
                    AxisGridLine()
                    AxisValueLabel(format: .dateTime.weekday(.abbreviated))
                }
            }
            .frame(height: 180)
            .padding(.horizontal)
            .padding(.bottom)
        }
        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
        .padding(.horizontal)
    }

    private func colorForQuality(_ q: Int) -> Color {
        switch q {
        case 5: return .indigo
        case 4: return .blue
        case 3: return .teal
        case 2: return .orange
        default: return .red
        }
    }
}

#Preview {
    SleepChartView()
        .modelContainer(for: SleepEntry.self, inMemory: true)
        .padding(.vertical)
}

7. Privacy Manifest

Apple requires a PrivacyInfo.xcprivacy file for any app using HealthKit or accessing the file system. Add the file to your app target in Xcode — without it, App Store Connect will reject your binary during processing. Use the Xcode template: File → New File → App Privacy.

<?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>NSPrivacyCollectedDataTypeHealth</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>NSPrivacyAccessedAPICategoryFileTimestamp</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array>
        <string>C617.1</string>
      </array>
    </dict>
  </array>
</dict>
</plist>

Common pitfalls

Adding monetization: Subscription

Use StoreKit 2's Product.products(for:) to load a monthly subscription product you've configured in App Store Connect under Monetization → Subscriptions. Gate premium features — the 14-night chart, CSV export, and custom sleep goal — behind an isPremium check in your SubscriptionManager. Listen for transaction updates with Transaction.updates so the premium state restores automatically on new devices or after reinstall. Always include a "Restore Purchases" button in your paywall; App Store review guidelines require it, and omitting it is a common rejection reason. Offer a 7-day free trial in App Store Connect to maximize conversion — sleep tracker users typically decide within a week whether the app fits their routine.

Shipping this faster with Soarias

Soarias handles the scaffolding that eats the first day of any HealthKit project: it generates the Xcode project with the HealthKit entitlement already added, drops in a PrivacyInfo.xcprivacy pre-populated for sleep analysis, wires up fastlane with match for code signing, and configures your App Store Connect app record — including the required NSHealthShareUsageDescription and NSHealthUpdateUsageDescription keys. It also pre-fills the App Store privacy nutrition labels for health data so you don't face that questionnaire cold during submission.

For an intermediate app like this one, the setup tasks above typically take 4–6 hours manually across Xcode, App Store Connect, and fastlane config files. With Soarias you go from blank slate to first TestFlight build in under an hour — leaving the week for what actually matters: refining the logging UX, tuning the chart, and polishing your paywall.

Related guides

FAQ

Does this work on iOS 16?

No, not as written. This guide uses SwiftData (iOS 17+), the #Preview macro (Xcode 15+), and the ContentUnavailableView API (iOS 17+). If you need iOS 16 support, replace SwiftData with Core Data or a JSON file store, use PreviewProvider, and add if #available(iOS 17, *) guards around ContentUnavailableView. Given that iOS 17+ adoption is above 90%, targeting iOS 17 is the pragmatic choice for a new app.

Do I need a paid Apple Developer account to test?

You can run the app on a personal device without a paid account using Xcode's free signing, but HealthKit entitlements require an active Apple Developer Program membership ($99/year). Without it, Xcode will build the app but HealthKit calls will fail silently at runtime. For local SwiftData-only testing you don't need the paid account, but you'll need it before shipping.

How do I add this to the App Store?

Archive your build in Xcode (Product → Archive), then use the Organizer window to upload to App Store Connect. From there, complete the required metadata: app description, screenshots for every device size, privacy nutrition labels (health data), age rating, and subscription pricing. Submit for review — HealthKit apps often get a human review, so expect 2–4 days. Make sure your PrivacyInfo.xcprivacy is included in the archive or it will be rejected during processing before review even starts.

Can I read historical sleep data that's already in Apple Health?

Yes — you requested read permission for .sleepAnalysis in Step 5. Query it with HKSampleQuery filtered by HKQuery.predicateForSamples(withStart:end:options:). Importing historic data is a great premium feature: fetch the last 90 days of Apple Watch or third-party sleep data on first launch and seed your SwiftData store. Be careful not to double-write samples your app already created — filter by HKMetadataKeyWasUserEntered or store the sample's uuid alongside your SleepEntry.

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