How to Build a Period Tracker App in SwiftUI
A period tracker app lets users log menstrual cycles, predict future periods from their personal history, and receive private on-device reminders — no account required. It's a high-retention health utility for people who want a private, local-first alternative to data-hungry mainstream apps.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- A real iOS 17+ device for testing
UserNotifications(the simulator suppresses scheduled triggers unreliably) - A published privacy policy URL — App Store Connect requires one for any app that handles health or reproductive data
Architecture overview
Cycle data lives entirely on-device in SwiftData — one CycleEntry record per period. A pure Swift CyclePredictionEngine computes the rolling average cycle length and drives both the home view prediction ring and UserNotifications scheduling. The Swift Charts framework renders a compact bar chart of recent cycle lengths. No server, no analytics SDK, no account — privacy is a feature you can market.
PeriodTrackerApp/
├── Models/
│ ├── CycleEntry.swift # @Model — persisted cycle records
│ └── FlowIntensity.swift # Codable enum
├── Views/
│ ├── CycleHomeView.swift # prediction ring + chart
│ ├── LogEntryView.swift # log new period sheet
│ └── InsightsView.swift # full Charts history
└── Engine/
├── CyclePredictionEngine.swift # rolling-average logic
└── ReminderScheduler.swift # UNUserNotificationCenter
Step-by-step
1. Data model with SwiftData
Define CycleEntry as a SwiftData @Model so every logged period persists automatically — no CoreData boilerplate, no manual saves.
import SwiftData
import Foundation
enum FlowIntensity: String, Codable, CaseIterable {
case light = "Light"
case medium = "Medium"
case heavy = "Heavy"
}
@Model
final class CycleEntry {
var startDate: Date
var endDate: Date?
var flowIntensity: FlowIntensity
var symptoms: [String]
var notes: String
init(startDate: Date, flowIntensity: FlowIntensity = .medium) {
self.startDate = startDate
self.flowIntensity = flowIntensity
self.symptoms = []
self.notes = ""
}
}
2. Core UI — CycleHomeView
Query all entries sorted by date, surface the next predicted period with a relative-time label, and expose a sheet for logging a new cycle.
struct CycleHomeView: View {
@Query(sort: \CycleEntry.startDate, order: .reverse)
private var cycles: [CycleEntry]
@State private var showLogSheet = false
var nextPeriod: Date? { CyclePredictionEngine.predict(from: cycles) }
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
PredictionRingView(nextDate: nextPeriod)
.frame(height: 200)
if let next = nextPeriod {
Text("Next period \(next, style: .relative)")
.font(.headline).foregroundStyle(.pink)
}
CycleHistoryChart(cycles: Array(cycles.prefix(6)))
.frame(height: 160).padding(.horizontal)
}
}
.navigationTitle("My Cycle")
.toolbar {
Button("Log Period", systemImage: "plus.circle.fill") {
showLogSheet = true
}
}
.sheet(isPresented: $showLogSheet) { LogEntryView() }
}
}
}
3. Cycle tracking and predictions
Compute the rolling average cycle length from all historical entries, fall back to 28 days for new users, then schedule a UNCalendarNotificationTrigger two days before the predicted start.
struct CyclePredictionEngine {
static func predict(from cycles: [CycleEntry]) -> Date? {
let sorted = cycles.sorted { $0.startDate < $1.startDate }
guard !sorted.isEmpty else { return nil }
guard sorted.count >= 2 else {
return Calendar.current.date(byAdding: .day, value: 28, to: sorted[0].startDate)
}
let lengths: [Int] = zip(sorted, sorted.dropFirst()).map {
Calendar.current.dateComponents([.day], from: $0.startDate, to: $1.startDate).day ?? 28
}
let avg = lengths.reduce(0, +) / lengths.count
return Calendar.current.date(byAdding: .day, value: avg, to: sorted.last!.startDate)
}
static func scheduleReminder(for date: Date) async throws {
let center = UNUserNotificationCenter.current()
try await center.requestAuthorization(options: [.alert, .sound])
let content = UNMutableNotificationContent()
content.title = "Period due soon"
content.body = "Your period is predicted to arrive in 2 days."
let fireDate = Calendar.current.date(byAdding: .day, value: -2, to: date)!
let comps = Calendar.current.dateComponents([.year,.month,.day,.hour], from: fireDate)
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: false)
try await center.add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger))
}
}
4. Privacy Manifest (PrivacyInfo.xcprivacy)
Add PrivacyInfo.xcprivacy to your app target (not just a framework bundle) — Apple requires it for any submission that collects health or fitness data.
<?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></plist>
Common pitfalls
- Requesting notification permission on cold launch. Apple guidelines require context before the system prompt. Trigger the
requestAuthorizationcall only after the user logs their first period — this also dramatically improves acceptance rates. - Missing Privacy Manifest causes metadata rejection. Reproductive health data is scrutinized heavily. If your
NSPrivacyCollectedDataTypeHealthAndFitnessentry is absent or miscategorized, App Store Review will reject before human review even begins. - No privacy policy URL in App Store Connect. Any app handling health or sensitive personal data must link a live privacy policy in both the App Store listing and inside the app. Missing either is a common rejection reason.
- SwiftData schema migration after shipping. Adding a property to
CycleEntrypost-launch requires aSchemaMigrationPlan. Plan your full model before v1 — retroactive migrations on health data are error-prone. - Simulator swallows scheduled notifications.
UNCalendarNotificationTriggerfires unreliably in simulators. Always validate reminder delivery on a physical device before submitting.
Adding monetization: Subscription
Use StoreKit 2's Product.products(for:) to fetch monthly and annual subscription SKUs you've configured in App Store Connect. Gate premium features — multi-cycle insights, symptom trend charts, and CSV export — behind a Transaction.currentEntitlement(for:) check rather than paywalling core logging, which App Store Review will reject. Present the paywall using .subscriptionStoreView(groupID:) for Apple's native subscription UI, which handles payment, restoration, and promotional offers automatically. Frame the subscription around privacy and depth of insights, not access to the tracker itself — that framing converts better and sails through review.
Shipping this faster with Soarias
Soarias handles the scaffolding that burns time on a health app: it generates the SwiftData model layer, wires the ModelContainer into your App entry point, creates a correctly keyed PrivacyInfo.xcprivacy, configures fastlane with your App Store Connect credentials, and submits the build complete with screenshots, metadata, and privacy nutrition labels — all without you touching App Store Connect manually.
For an intermediate project at this complexity, most developers spend two to three days on Xcode project setup, Privacy Manifest research, and ASC configuration before writing a single line of product code. Soarias compresses that to under an hour, so your full week lands on prediction logic, UI polish, and subscription paywall implementation — the parts that actually determine whether your app succeeds.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. A free Apple ID lets you run the app on your own device during development, but distributing via TestFlight or submitting to the App Store requires the $99/year Apple Developer Program. Enroll at developer.apple.com before you start — account activation can take 24–48 hours.
How do I submit this to the App Store?
Archive the app in Xcode via Product → Archive, then upload through Xcode Organizer. In App Store Connect, complete your listing — screenshots for all required device sizes, description, age rating, privacy nutrition labels, and your privacy policy URL. Health-related apps receive careful human review; budget 3–5 business days and be prepared to answer questions about your data handling practices.
Should I integrate HealthKit for cycle data?
HealthKit's HKCategoryTypeIdentifier.menstrualFlow lets users share data with Apple Health and third-party apps, which is a real differentiator. However, it requires a physical device for all testing, an additional entitlement, and HealthKit-specific App Store review scrutiny. For v1, SwiftData-only is faster to ship and easier to reason about. Add HealthKit sync in a follow-up release once your core prediction loop is validated by real users.
Last reviewed: 2026-05-12 by the Soarias team.