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.
Prerequisites
- Mac with Xcode 16+ installed
- Apple Developer Program ($99/year) — required for TestFlight, App Store, and HealthKit entitlements on device
- Basic Swift and SwiftUI knowledge (you can follow Apple's SwiftUI tutorial first)
- A physical iPhone for testing HealthKit — writing sleep analysis samples does not work reliably on the iOS Simulator
- Familiarity with Swift Concurrency (
async/await) — HealthKit and StoreKit 2 are fully async
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
- HealthKit won't work on the Simulator for writes.
HKHealthStore.save(_:)returns aHKError.errorHealthDataUnavailableerror on the Simulator for sleep analysis writes. Always test HealthKit on a real device. Use a conditional so the app gracefully falls back to local-only mode. - HealthKit authorization is opaque by design. Apple doesn't tell your app whether the user denied or granted permission — to protect privacy. Don't show a "permission denied" error after
requestAuthorization; instead show a "Check Health permissions in Settings" tip if writes silently fail. - Bedtime crossing midnight breaks date math. If a user logs bedtime as 11 PM and wake time as 7 AM,
wakeTime.timeIntervalSince(bedtime)is negative unless you clamp or roll over the date. Always usemax(0, ...)and validate the window is ≤ 24 hours before saving. - App Store review: HealthKit requires a clearly stated purpose. Add
NSHealthShareUsageDescriptionandNSHealthUpdateUsageDescriptionto Info.plist with a plain-English explanation. Vague strings like "We use Health data" will get flagged. Write something like "SleepTracker writes your sleep sessions to Apple Health so they appear alongside other health data." - SwiftData @Query doesn't auto-filter deleted objects mid-session. If you delete an entry and immediately read
entries.countin the same run loop tick, you may get a stale count. Usetry? modelContext.save()after bulk deletes, or rely on the next SwiftUI refresh cycle.
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.