How to Build a Posture Reminder App in SwiftUI
A Posture Reminder app sends periodic local notifications that prompt users to sit up straight and adjust their position throughout the workday. It's ideal for desk workers and anyone who wants a small, focused wellness utility on their iPhone.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- A physical iPhone for notification testing — the Simulator does not reliably deliver local notifications
Architecture overview
The app stores a single PostureSchedule SwiftData model that persists the reminder interval and active-hours window. ContentView reads and writes the model via @Query and @Bindable. A lightweight NotificationScheduler service translates the current schedule into UNCalendarNotificationTrigger requests — one per time-slot — so iOS delivers nudges reliably even when the app is backgrounded or closed.
PostureReminder/
├── PostureReminderApp.swift # @main + modelContainer setup
├── Models/
│ └── PostureSchedule.swift # @Model: interval, active hours
├── Views/
│ └── ContentView.swift # Form: toggle, interval, hours
└── Services/
└── NotificationScheduler.swift # UNUserNotificationCenter logic
Step-by-step
1. Data model
Define a PostureSchedule SwiftData model to persist the user's reminder interval and active-hours window so settings survive app restarts.
import SwiftData
import Foundation
@Model
final class PostureSchedule {
var isEnabled: Bool
var intervalMinutes: Int // e.g. 20, 30, 45, 60
var startHour: Int // 0–23, e.g. 9 for 9 am
var endHour: Int // 0–23, e.g. 18 for 6 pm
var reminderMessage: String
init(
isEnabled: Bool = true,
intervalMinutes: Int = 30,
startHour: Int = 9,
endHour: Int = 18,
reminderMessage: String = "Sit up straight and roll your shoulders back."
) {
self.isEnabled = isEnabled
self.intervalMinutes = intervalMinutes
self.startHour = startHour
self.endHour = endHour
self.reminderMessage = reminderMessage
}
}
2. Core UI
Build a Form-based view that lets users toggle reminders and configure interval and active hours, writing directly to the SwiftData model via @Bindable.
struct ContentView: View {
@Query private var schedules: [PostureSchedule]
@Environment(\.modelContext) private var ctx
private var s: PostureSchedule {
schedules.first ?? { let n = PostureSchedule(); ctx.insert(n); return n }()
}
var body: some View {
NavigationStack {
Form {
Section("Reminders") {
Toggle("Enabled", isOn: Bindable(s).isEnabled)
Picker("Interval", selection: Bindable(s).intervalMinutes) {
ForEach([10, 20, 30, 45, 60], id: \.self) { Text("Every \($0) min").tag($0) }
}
}
Section("Active hours") {
Picker("From", selection: Bindable(s).startHour) {
ForEach(6..<22) { Text("\($0):00").tag($0) }
}
Picker("Until", selection: Bindable(s).endHour) {
ForEach(7..<23) { Text("\($0):00").tag($0) }
}
}
}
.navigationTitle("Posture Reminder")
.onChange(of: s.intervalMinutes) { _, _ in
Task { await NotificationScheduler.shared.scheduleRepeating(
every: s.intervalMinutes, from: s.startHour, to: s.endHour) }
}
}
}
}
3. Periodic posture nudges
Schedule repeating UNCalendarNotificationTrigger requests — one per active time-slot — so the system delivers nudges daily without any background processing.
@MainActor
final class NotificationScheduler {
static let shared = NotificationScheduler()
private let center = UNUserNotificationCenter.current()
func requestPermission() async -> Bool {
(try? await center.requestAuthorization(options: [.alert, .sound])) ?? false
}
func scheduleRepeating(every minutes: Int, from start: Int, to end: Int) async {
await center.removeAllPendingNotificationRequests()
let content = UNMutableNotificationContent()
content.title = "Posture check"
content.body = "Sit up straight and roll your shoulders back."
content.sound = .default
var count = 0
for hour in start..
4. Privacy Manifest
Add a PrivacyInfo.xcprivacy file to your Xcode target — App Store Connect will reject uploads that access certain system APIs without one.
<?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>NSPrivacyCollectedDataTypes</key><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
- Hitting the 64-notification cap. iOS limits each app to 64 pending notification requests. A 10-minute interval over a 12-hour window generates 72 time-slots. Enforce a minimum interval of 12 minutes, or warn users when the active window is too wide for their chosen interval.
- Notifications that never fire on Simulator. The iOS Simulator does not reliably deliver local notifications. Always verify schedule logic on a physical device before concluding there is a code bug.
- Stale notifications after settings change. SwiftData writes persist immediately, but the pending notification queue does not self-update. Call
scheduleRepeatinginside everyonChangemodifier that touches interval, start hour, or end hour. - Missing
NSUserNotificationUsageDescriptionin Info.plist. Without this key, iOS silently skips the permission dialog on first launch. Add it with a plain-language string explaining why reminders are needed. - App Store review: paywalling every reminder. Reviewers reject apps where a purchase is the only way to receive any notification. Keep at least one free interval option (e.g. 60-minute reminders) and gate shorter intervals or custom messages behind the unlock.
Adding monetization: One-time purchase
Implement a one-time unlock with StoreKit 2's Product.purchase() API. Define a non-consumable in-app purchase in App Store Connect (e.g. com.yourapp.unlock), load it with Product.products(for:) on launch, and gate premium features — custom reminder text, intervals shorter than 30 minutes, or a silent haptic-only mode — behind a check on product.currentEntitlement. Because it's a non-consumable, StoreKit automatically restores the purchase on reinstall; you still need a visible Restore Purchases button in your UI to pass App Store review.
Shipping this faster with Soarias
Soarias scaffolds the complete Xcode project from a prompt — PostureSchedule SwiftData model, NotificationScheduler service, ContentView Form, StoreKit 2 paywall, and PrivacyInfo.xcprivacy — so you skip the boilerplate that typically consumes the first evening. It also wires up fastlane lanes for building, signing, and uploading to App Store Connect, removing the most error-prone manual steps in the submission flow.
For a beginner app at this complexity level, most developers spend 4–6 hours on Xcode project setup, provisioning profiles, and App Store Connect metadata before writing any product logic. Soarias compresses that overhead to under 30 minutes, leaving your 1–2 weekends free for refining the notification schedule and testing on device.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. A free account lets you sideload onto your own device for testing, but you need an Apple Developer Program membership ($99/year) to distribute via TestFlight or the App Store and to configure in-app purchases in App Store Connect.
How do I submit this to the App Store?
Archive your app in Xcode (Product → Archive), then use Organizer to upload to App Store Connect. Complete required metadata (name, subtitle, description, screenshots for all required device sizes), set pricing, attach the in-app purchase, and submit for review. First submissions typically take 1–3 business days. Soarias automates the archive, upload, and metadata steps via fastlane.
Can I send reminders only on weekdays?
Yes. Add a weekday component to your DateComponents — values 2 through 6 cover Monday through Friday in the Gregorian calendar. You'll create five sets of time-slot requests (one per weekday), so watch the 64-request cap closely. With a 30-minute interval over a 9-hour window, five weekdays produce 90 requests — you'll need to narrow the active window or raise the minimum interval to stay within the limit.
Last reviewed: 2026-05-12 by the Soarias team.