How to Build a Medication Reminder App in SwiftUI
A Medication Reminder app lets users set up repeating pill schedules, get notified at the right time each day, and log whether they took each dose. It's built for people managing chronic conditions or caregivers keeping family members on track.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- A physical iOS device is strongly recommended — simulator notification delivery is unreliable for testing repeating calendar triggers
- No HealthKit entitlement required for a basic implementation; add it only if you intend to read/write to the Health app
Architecture overview
The app is entirely local-first: SwiftData persists Medication and MedSchedule records on-device with no backend needed. A thin NotificationScheduler service bridges SwiftData models to UserNotifications, scheduling a UNCalendarNotificationTrigger for each weekday in each schedule. SwiftUI views powered by @Query stay in sync with the model context automatically, and a StoreKit 2 paywall gates premium features behind a subscription.
MedicationReminder/ ├── Models/ │ ├── Medication.swift # @Model: name, dosage, colorHex, isActive │ └── MedSchedule.swift # @Model: time, weekdays, takenDates ├── Views/ │ ├── MedicationListView.swift # @Query list + Add sheet │ ├── AddMedicationView.swift # Form with DatePicker + weekday toggles │ └── MedDetailView.swift # Dose history + taken toggle ├── Services/ │ └── NotificationScheduler.swift └── PrivacyInfo.xcprivacy
Step-by-step
1. Data model
Define Medication and MedSchedule as SwiftData @Model classes with a cascade-delete relationship so removing a medication also removes all its schedules.
import SwiftData
import Foundation
@Model final class Medication {
var name: String
var dosage: String
var colorHex: String
var isActive: Bool
@Relationship(deleteRule: .cascade)
var schedules: [MedSchedule] = []
init(name: String, dosage: String, colorHex: String = "#4F8EF7") {
self.name = name; self.dosage = dosage
self.colorHex = colorHex; self.isActive = true
}
}
@Model final class MedSchedule {
var time: Date // only hour + minute are used
var weekdays: [Int] // 1 = Sun … 7 = Sat (Calendar weekday convention)
var takenDates: [Date] = []
init(time: Date, weekdays: [Int]) {
self.time = time; self.weekdays = weekdays
}
}
2. Core UI
Drive MedicationListView with @Query so insertions and deletions reflect instantly without manual state management.
import SwiftUI
import SwiftData
struct MedicationListView: View {
@Query(sort: \Medication.name) private var meds: [Medication]
@Environment(\.modelContext) private var context
@State private var showAdd = false
var body: some View {
NavigationStack {
List {
ForEach(meds) { med in
NavigationLink(value: med) {
Label(med.name, systemImage: "pill.fill")
.badge(med.schedules.count)
}
}
.onDelete { idx in idx.forEach { context.delete(meds[$0]) } }
}
.navigationTitle("Medications")
.navigationDestination(for: Medication.self) { MedDetailView(medication: $0) }
.toolbar { ToolbarItem(placement: .primaryAction) {
Button("Add", systemImage: "plus") { showAdd = true }
}}
.sheet(isPresented: $showAdd) { AddMedicationView() }
}
}
}
3. Pill schedule notifications
Schedule a repeating UNCalendarNotificationTrigger per weekday per schedule, using .timeSensitive interruption level so alerts surface through Focus modes when it matters most.
import UserNotifications
@MainActor final class NotificationScheduler {
static let shared = NotificationScheduler()
private let center = UNUserNotificationCenter.current()
func requestAuth() async -> Bool {
(try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
}
func reschedule(for med: Medication) async {
let prefix = "\(med.persistentModelID.hashValue)"
let ids = (await center.pendingNotificationRequests())
.filter { $0.identifier.hasPrefix(prefix) }.map(\.identifier)
center.removePendingNotificationRequests(withIdentifiers: ids)
guard med.isActive else { return }
for sched in med.schedules {
for day in sched.weekdays {
let c = UNMutableNotificationContent()
c.title = "Take \(med.name)"; c.body = med.dosage
c.sound = .default; c.interruptionLevel = .timeSensitive
var dc = Calendar.current.dateComponents([.hour, .minute], from: sched.time)
dc.weekday = day
try? await center.add(UNNotificationRequest(
identifier: "\(prefix)-\(day)", content: c,
trigger: UNCalendarNotificationTrigger(dateMatching: dc, repeats: true)))
}
}
}
}
4. Privacy Manifest (PrivacyInfo.xcprivacy)
Add a PrivacyInfo.xcprivacy file to your Xcode target — App Store Connect rejects submissions that access required-reason APIs without a declared manifest.
<?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/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>CA92.1</string></array>
</dict>
</array>
</dict>
</plist>
Common pitfalls
- Requesting notification permission at launch. Apple guideline 2.5.13 rejects apps that prompt for permissions immediately on first open with no context. Trigger
requestAuth()only when the user saves their first schedule, not inApp.init. - Missing Time Sensitive Notifications entitlement. Setting
interruptionLevel = .timeSensitivewithout the corresponding entitlement silently degrades to.active— the alert won't pierce Focus modes. Add the capability under Signing & Capabilities. - Hitting iOS's 64-pending-notification cap. Ten medications × 2 daily doses × 7 weekdays = 140 requests. Schedule only the next 7 days and call
rescheduleagain insceneDidBecomeActiveto stay within the limit. - SwiftData schema migration crashes on update. Adding a non-optional property to
Medicationafter shipping will crash existing users during the store migration. Always provide a default or useVersionedSchemawith aMigrationPlanfor any additive changes. - Missing NSUserNotificationsUsageDescription. The App Store binary upload will fail if
Info.plistis missing this key with a plain-language explanation of why the app sends notifications.
Adding monetization: Subscription
Use StoreKit 2's Product.products(for:) to load a monthly or annual subscription configured in App Store Connect, then gate premium features — multiple family member profiles, a PDF dose-history export, a home screen widget — behind a Transaction.currentEntitlement(for:) check. Present the paywall with SubscriptionStoreView (iOS 17+), which handles purchasing, restoration, and introductory offer display with minimal boilerplate. Attach a Transaction.updates listener at app launch so renewals and refunds are reflected immediately without requiring the user to reopen the app.
Shipping this faster with Soarias
Soarias scaffolds the full project from your concept in a single Claude Code session — SwiftData models with the cascade-delete relationship wired up, NotificationScheduler, the PrivacyInfo.xcprivacy manifest pre-filled for UserDefaults access, and a SubscriptionStoreView paywall stub. It then generates the fastlane Deliverfile, captures App Store screenshots across the required 6.9″ and 6.5″ device sizes, and populates all required App Store Connect metadata so you're not blocked by review-prep busywork.
For an intermediate project at this complexity, most developers lose two to three days on notification scheduling edge cases and another day on App Store Connect setup and screenshot production. With Soarias handling that scaffolding and submission pipeline, a realistic calendar estimate drops from a full week to a long weekend.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. The $99/year Apple Developer Program membership is required for TestFlight distribution and App Store submission. You can run the app on your own device for free using a personal team, but that provisioning profile expires every 7 days and cannot be used for external testing.
How do I submit this to the App Store?
Archive in Xcode via Product → Archive, then validate and distribute through Organizer to App Store Connect. You'll need screenshots for iPhone 6.9″ and 6.5″ displays, a support URL, a privacy policy URL (required when you handle health-adjacent data), and completed privacy nutrition labels in App Store Connect before Apple will start reviewing.
Can users sync their medication data across devices with iCloud?
Yes — SwiftData supports CloudKit sync with minimal code changes. Enable the iCloud and CloudKit capabilities in your target, then pass ModelConfiguration(cloudKitDatabase: .automatic) to your ModelContainer. All @Model properties must be optional or have defaults for CloudKit compatibility. Because medication records are sensitive, update your privacy policy and App Store privacy nutrition labels to reflect that this data is synced across the user's own devices.
Last reviewed: 2026-05-12 by the Soarias team.