How to Build an Eye Rest Reminder App in SwiftUI
An Eye Rest Reminder app uses the 20-20-20 rule — every 20 minutes, look at something 20 feet away for 20 seconds — to protect users from digital eye strain. It's the perfect beginner project for developers who want to ship something genuinely useful to desk workers, students, and anyone staring at screens all day.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge (you know what a View and a State variable are)
- A physical iPhone or iPad for testing UserNotifications — the simulator delivers notifications but background behavior differs from a real device
Architecture overview
The app is intentionally thin. A single @Observable class — EyeRestManager — owns all state: whether a session is active, how many seconds remain on the current 20-minute interval, and whether the user is in the 20-second rest window. SwiftUI views observe this object directly. Notifications are scheduled via UNUserNotificationCenter on session start and cancelled on stop. There's no networking, no database, and no iCloud sync — AppStorage keeps one or two preferences (custom interval, sound toggle) in UserDefaults. StoreKit handles the one-time unlock.
EyeRestReminder/ ├── App/ │ └── EyeRestReminderApp.swift # @main, scene setup ├── Model/ │ └── EyeRestManager.swift # @Observable, Timer, UNUserNotification ├── Views/ │ ├── ContentView.swift # Root tab/navigation │ ├── TimerView.swift # Countdown ring + start/stop │ ├── RestView.swift # 20-second rest overlay │ └── SettingsView.swift # Interval picker, sound toggle ├── Store/ │ └── StoreManager.swift # StoreKit 2 one-time purchase ├── Resources/ │ └── PrivacyInfo.xcprivacy # Required reason API declarations └── EyeRestReminder.entitlements
Step-by-step
1. Create the Xcode project
Open Xcode 16, choose File → New → Project → iOS → App, set Interface to SwiftUI and Storage to None. Then enable the Push Notifications and Background Modes → Background fetch capabilities in the Signing & Capabilities tab — UserNotifications needs these to deliver alerts when the app is backgrounded.
// EyeRestReminderApp.swift
import SwiftUI
import UserNotifications
@main
struct EyeRestReminderApp: App {
@State private var manager = EyeRestManager()
var body: some Scene {
WindowGroup {
ContentView()
.environment(manager)
.onAppear {
Task { await requestNotificationPermission() }
}
}
}
private func requestNotificationPermission() async {
let center = UNUserNotificationCenter.current()
_ = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
}
}
2. Build the timer data model
Use @Observable (Swift 5.9+ macro) to create EyeRestManager. It holds the session state, the live countdown, and a Timer.TimerPublisher. Avoid mixing @Observable with ObservableObject — pick one. Here we use @Observable because it's less boilerplate and works great with SwiftUI's automatic dependency tracking.
// Model/EyeRestManager.swift
import Foundation
import Observation
import UserNotifications
@Observable
final class EyeRestManager {
// Persisted preferences
var workIntervalMinutes: Int = 20 // backed by AppStorage in SettingsView
var restIntervalSeconds: Int = 20
// Live session state
var isSessionActive: Bool = false
var isResting: Bool = false
var secondsRemaining: Int = 20 * 60 // default 20 min
private var countdownTimer: Timer?
// MARK: - Session control
func startSession() {
isSessionActive = true
isResting = false
secondsRemaining = workIntervalMinutes * 60
scheduleNotifications()
startCountdown()
}
func stopSession() {
isSessionActive = false
isResting = false
countdownTimer?.invalidate()
countdownTimer = nil
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
}
// MARK: - Countdown
private func startCountdown() {
countdownTimer?.invalidate()
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
guard let self else { return }
if self.secondsRemaining > 0 {
self.secondsRemaining -= 1
} else {
self.triggerRest()
}
}
}
private func triggerRest() {
isResting = true
secondsRemaining = restIntervalSeconds
countdownTimer?.invalidate()
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
guard let self else { return }
if self.secondsRemaining > 0 {
self.secondsRemaining -= 1
} else {
self.isResting = false
self.secondsRemaining = self.workIntervalMinutes * 60
self.startCountdown()
}
}
}
}
3. Build the main timer UI
A circular progress ring gives users instant visual feedback on how much work time remains. Build it with two Circle shapes — a track and a progress arc — and animate the stroke trim whenever secondsRemaining changes. Keep the view dumb: it only reads from the model, never mutates it directly.
// Views/TimerView.swift
import SwiftUI
struct TimerView: View {
@Environment(EyeRestManager.self) private var manager
private var progress: Double {
let total = Double(manager.workIntervalMinutes * 60)
return 1 - (Double(manager.secondsRemaining) / total)
}
var body: some View {
VStack(spacing: 32) {
Text(manager.isResting ? "Rest your eyes" : "Work session")
.font(.headline)
.foregroundStyle(.secondary)
ZStack {
Circle()
.stroke(Color.secondary.opacity(0.2), lineWidth: 14)
.frame(width: 220, height: 220)
Circle()
.trim(from: 0, to: manager.isResting ? 1 : progress)
.stroke(
manager.isResting ? Color.green : Color.blue,
style: StrokeStyle(lineWidth: 14, lineCap: .round)
)
.rotationEffect(.degrees(-90))
.animation(.linear(duration: 1), value: manager.secondsRemaining)
.frame(width: 220, height: 220)
VStack(spacing: 4) {
Text(timeString(manager.secondsRemaining))
.font(.system(size: 52, weight: .thin, design: .rounded))
Text(manager.isResting ? "look 20 ft away" : "until next break")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Button(manager.isSessionActive ? "Stop" : "Start session") {
manager.isSessionActive ? manager.stopSession() : manager.startSession()
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.tint(manager.isSessionActive ? .red : .blue)
}
.padding()
}
private func timeString(_ seconds: Int) -> String {
let m = seconds / 60
let s = seconds % 60
return String(format: "%02d:%02d", m, s)
}
}
#Preview {
TimerView()
.environment(EyeRestManager())
}
4. Schedule 20-20-20 notifications
The in-app countdown covers foreground use, but users will lock their phones. UNUserNotificationCenter with a repeating UNTimeIntervalNotificationTrigger fires the reminder even when the app is in the background. Schedule all the notifications up front for the session so there's no dependency on background runtime.
// Add to EyeRestManager.swift
extension EyeRestManager {
func scheduleNotifications() {
let center = UNUserNotificationCenter.current()
center.removeAllPendingNotificationRequests()
// Schedule 8 hours worth of break reminders
let totalBreaks = (8 * 60) / workIntervalMinutes
for i in 1...totalBreaks {
let content = UNMutableNotificationContent()
content.title = "Eye break time 👁"
content.body = "Look at something 20 feet away for 20 seconds."
content.sound = .default
content.interruptionLevel = .timeSensitive
let trigger = UNTimeIntervalNotificationTrigger(
timeInterval: TimeInterval(i * workIntervalMinutes * 60),
repeats: false
)
let request = UNNotificationRequest(
identifier: "eye-rest-\(i)",
content: content,
trigger: trigger
)
center.add(request)
}
}
}
5. Add Privacy Manifest
Apple requires a PrivacyInfo.xcprivacy file in every app that uses "required reason" APIs. This app uses UserDefaults (AppStorage), which falls under NSPrivacyAccessedAPICategoryUserDefaults. Missing this file will cause an automatic rejection during App Store upload — add it before you submit, not after.
<!-- Resources/PrivacyInfo.xcprivacy -->
<?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>
<!-- CA92.1: Access own app's defaults only -->
<string>CA92.1</string>
</array>
</dict>
</array>
</dict>
</plist>
Common pitfalls
- Notifications not firing in the background: The simulator delivers local notifications but doesn't simulate the full background lifecycle. Always test session + lock screen behavior on a physical device before submitting.
- Requesting notification permission at the wrong moment: Apple's HIG says to ask for permission in context — when the user taps "Start session", not at cold launch. Apps that request permissions immediately on first open have lower opt-in rates and can trigger App Store review feedback.
- Scheduling too many notifications: iOS caps pending notification requests at 64 per app. Scheduling 8 hours × 5-minute intervals = 96 requests — you'll silently hit the cap. Clamp your scheduled count to 60 and reschedule when the app returns to the foreground.
- App Store review: missing notification usage description: Even though
NSUserNotificationsUsageDescriptionisn't required in Info.plist (UserNotifications uses runtime permission), reviewers may reject apps whose notification wording is unclear. Keep yourcontent.bodydescriptive and directly tied to the app's purpose. - Using
Timerwithout a RunLoop mode: Scheduling aTimeron the default RunLoop will pause when the user scrolls a list. Add it to.commonmode —RunLoop.main.add(timer, forMode: .common)— or useTimer.scheduledTimerwhich does this automatically when called on the main thread.
Adding monetization: One-time purchase
Use StoreKit 2 (introduced iOS 15, fully async/await) to offer a one-time "Pro" unlock that removes a daily session cap or unlocks custom intervals. Create a non-consumable in-app purchase product in App Store Connect — set the type to Non-Consumable and give it a product ID like com.yourapp.eyerest.pro. In code, use Product.products(for:) to fetch the product, product.purchase() to initiate the transaction, and verify entitlement with Transaction.currentEntitlement(for:) on app launch. StoreKit 2 handles receipt validation server-side automatically — you don't need your own backend. Gate the premium features behind a @AppStorage("isPro") flag that you flip after a verified transaction, and always call AppStore.sync() on the settings screen so users can restore purchases after reinstalling.
Shipping this faster with Soarias
Soarias handles the scaffolding, Privacy Manifest, and App Store submission steps that trip up most first-time shippers. When you start an Eye Rest Reminder project in Soarias, it generates the Xcode project with the correct entitlements already wired, creates a valid PrivacyInfo.xcprivacy for UserDefaults usage, sets up fastlane lanes for screenshots and binary upload, and walks you through the ASC metadata fields — app description, keywords, privacy nutrition labels — without you ever opening App Store Connect manually.
For a beginner project like this one, the real time sink isn't writing the 200 lines of SwiftUI — it's the first App Store submission: provisioning profiles, screenshot sizes across six device types, the review information form, and the first rejection (almost always the Privacy Manifest or notification wording). Soarias users typically cut that submission overhead from a full weekend of confusion down to an hour or two, which means a 1–2 weekend project ships in one.
Related guides
FAQ
Does this work on iOS 16?
The @Observable macro requires iOS 17. If you need iOS 16 support, replace @Observable with @MainActor class EyeRestManager: ObservableObject and use @Published on each property. Everything else — UserNotifications, SwiftUI views, StoreKit 2 — works on iOS 16. That said, iOS 17 is on over 90% of active devices as of 2026, so targeting iOS 17 is a reasonable default for new apps.
Do I need a paid Apple Developer account to test?
No — you can run the app on a personal device with a free Apple ID. However, a free account can't push to TestFlight or the App Store, and some capabilities (like certain push notification entitlements) require a paid account. For everything in this tutorial, a free account is sufficient for local testing. You'll need the $99/year paid membership before you submit.
How do I add this to the App Store?
Create an app record in App Store Connect at appstoreconnect.apple.com. Fill in the name, subtitle, description, keywords (max 100 chars), and support URL. Upload screenshots for at least iPhone 6.9" (iPhone 16 Pro Max) and iPhone 6.5" sizes — you can generate them with Xcode's simulator. Archive the app in Xcode (Product → Archive), validate in Organizer, and submit for review. First-time submissions typically take 1–3 days for review.
My notifications aren't firing when the phone is locked — what's wrong?
This is the most common beginner issue with UserNotifications. First, check that the user granted permission (UNUserNotificationCenter.current().notificationSettings() — .authorizationStatus should be .authorized). Second, make sure you're not hitting the 64-notification cap — log center.pendingNotificationRequests() to count them. Third, confirm you called center.add(request) and didn't catch a silent error. On iOS 17+, interruptionLevel: .timeSensitive helps notifications break through Focus modes, though the user can still block them.
Last reviewed: 2026-05-11 by the Soarias team.