```html How to Build an Eye Rest Reminder App in SwiftUI (2026)

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.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Beginner Estimated time: 1–2 weekends Updated: May 11, 2026

Prerequisites

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

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.

```