```html How to Build a Pomodoro Timer App in SwiftUI (2026)

How to Build a Pomodoro Timer App in SwiftUI

A Pomodoro Timer app alternates focused 25-minute work sprints with 5-minute breaks, firing a local notification the moment each phase ends so users never lose track. It's the perfect first real-world iOS project: small scope, satisfying animation, a genuine use case, and a clear path to the App Store.

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

Prerequisites

Architecture overview

The app is a single-screen SwiftUI view driven by one @Observable class — PomodoroTimer — that owns the countdown logic, phase transitions (work → break → work), and UNUserNotificationCenter scheduling. There is no remote networking and no persistent database; a simple UserDefaults write stores the cumulative pomodoro count across launches. SwiftUI's withAnimation and a Canvas-drawn ring handle all visual feedback. The flat structure keeps the project approachable while still showing clean separation of concerns.

PomodoroTimer/
├── PomodoroTimerApp.swift          # @main entry point
├── Models/
│   └── PomodoroTimer.swift         # @Observable — countdown, phases, notifications
├── Views/
│   ├── ContentView.swift           # Root view, injects environment object
│   ├── TimerRingView.swift         # Animated circular progress ring
│   └── SessionCountView.swift      # Completed pomodoro badges
├── Helpers/
│   └── NotificationManager.swift   # UNUserNotificationCenter wrapper
└── PrivacyInfo.xcprivacy           # Required App Store privacy manifest

Step-by-step

1. Create the Xcode project

Open Xcode 16, choose File → New → Project, pick App under iOS, set the interface to SwiftUI and storage to None. Give it a reverse-DNS bundle ID you own (e.g. com.yourname.pomodoro). Under Signing & Capabilities add the Push Notifications capability — this is required even for local notifications on device. Enable Background Modes → Background fetch so the OS can fire notifications when the app is suspended.

// PomodoroTimerApp.swift
import SwiftUI

@main
struct PomodoroTimerApp: App {
    @State private var pomodoroTimer = PomodoroTimer()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(pomodoroTimer)
        }
    }
}

2. Model timer state with @Observable

The PomodoroTimer class is the single source of truth. Using the @Observable macro (iOS 17+) means any SwiftUI view that reads a property automatically re-renders when it changes — no @Published boilerplate needed. A Foundation.Timer fires every second and decrements timeRemaining; when it hits zero the model advances the phase and persists the completed count to UserDefaults.

// Models/PomodoroTimer.swift
import Foundation
import Observation

@Observable
final class PomodoroTimer {

    // MARK: - Phase

    enum Phase: String {
        case work       = "Focus"
        case shortBreak = "Break"

        var duration: Int {
            switch self {
            case .work:       return 25 * 60
            case .shortBreak: return  5 * 60
            }
        }
    }

    // MARK: - Published state

    var phase: Phase = .work
    var timeRemaining: Int = Phase.work.duration
    var isRunning: Bool = false
    var completedPomodoros: Int {
        didSet { UserDefaults.standard.set(completedPomodoros, forKey: "completedPomodoros") }
    }

    // MARK: - Derived

    var progress: Double {
        1.0 - Double(timeRemaining) / Double(phase.duration)
    }

    var timeString: String {
        String(format: "%02d:%02d", timeRemaining / 60, timeRemaining % 60)
    }

    // MARK: - Private

    private var ticker: Timer?

    // MARK: - Init

    init() {
        completedPomodoros = UserDefaults.standard.integer(forKey: "completedPomodoros")
    }

    // MARK: - Controls

    func start() {
        guard !isRunning else { return }
        isRunning = true
        NotificationManager.shared.scheduleEndNotification(after: timeRemaining, phase: phase)
        ticker = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
            self?.tick()
        }
        RunLoop.main.add(ticker!, forMode: .common)
    }

    func pause() {
        isRunning = false
        ticker?.invalidate()
        ticker = nil
        NotificationManager.shared.cancelPending()
    }

    func reset() {
        pause()
        timeRemaining = phase.duration
    }

    // MARK: - Private

    private func tick() {
        if timeRemaining > 0 {
            timeRemaining -= 1
        } else {
            advance()
        }
    }

    private func advance() {
        pause()
        if phase == .work {
            completedPomodoros += 1
            phase = .shortBreak
        } else {
            phase = .work
        }
        timeRemaining = phase.duration
    }
}

3. Build the animated timer ring UI

A Circle stroke with trim(from:to:) creates a clean progress arc. Wrapping the update in withAnimation(.linear(duration: 1)) ensures the ring advances smoothly each second rather than jumping. Keep the ring view dumb — it takes progress and phase as inputs and renders nothing else.

// Views/TimerRingView.swift
import SwiftUI

struct TimerRingView: View {
    let progress: Double   // 0.0 → 1.0
    let phase: PomodoroTimer.Phase
    let timeString: String

    private var ringColor: Color {
        phase == .work ? Color.red : Color.green
    }

    var body: some View {
        ZStack {
            // Track
            Circle()
                .stroke(ringColor.opacity(0.15), lineWidth: 18)

            // Progress arc
            Circle()
                .trim(from: 0, to: progress)
                .stroke(
                    ringColor,
                    style: StrokeStyle(lineWidth: 18, lineCap: .round)
                )
                .rotationEffect(.degrees(-90))
                .animation(.linear(duration: 1), value: progress)

            // Labels
            VStack(spacing: 4) {
                Text(timeString)
                    .font(.system(size: 52, weight: .semibold, design: .monospaced))
                Text(phase.rawValue.uppercased())
                    .font(.caption)
                    .foregroundStyle(.secondary)
                    .kerning(2)
            }
        }
        .padding(24)
    }
}

#Preview {
    TimerRingView(progress: 0.4, phase: .work, timeString: "15:00")
        .frame(width: 300, height: 300)
}

4. Wire up 25/5 cycles and local notifications

The NotificationManager wrapper requests authorisation once, then fires a UNTimeIntervalNotificationTrigger each time the user starts a session. This means even if the user locks their screen mid-focus the OS delivers the alert at the right moment. Cancel pending notifications on pause so stale alerts don't fire after the user resets.

// Helpers/NotificationManager.swift
import UserNotifications

final class NotificationManager {
    static let shared = NotificationManager()
    private init() {}

    func requestAuthorisation() {
        UNUserNotificationCenter.current().requestAuthorization(
            options: [.alert, .sound, .badge]
        ) { _, _ in }
    }

    func scheduleEndNotification(after seconds: Int, phase: PomodoroTimer.Phase) {
        cancelPending()

        let content = UNMutableNotificationContent()
        content.sound = .default

        switch phase {
        case .work:
            content.title = "Focus session complete 🍅"
            content.body  = "Time for a 5-minute break."
        case .shortBreak:
            content.title = "Break over!"
            content.body  = "Ready for another 25 minutes?"
        }

        let trigger = UNTimeIntervalNotificationTrigger(
            timeInterval: TimeInterval(seconds),
            repeats: false
        )
        let request = UNNotificationRequest(
            identifier: "pomodoro.end",
            content: content,
            trigger: trigger
        )
        UNUserNotificationCenter.current().add(request)
    }

    func cancelPending() {
        UNUserNotificationCenter.current()
            .removePendingNotificationRequests(withIdentifiers: ["pomodoro.end"])
    }
}

// Views/ContentView.swift
import SwiftUI

struct ContentView: View {
    @Environment(PomodoroTimer.self) private var timer

    var body: some View {
        VStack(spacing: 32) {
            Text("Pomodoro")
                .font(.title2.weight(.bold))
                .padding(.top, 24)

            TimerRingView(
                progress: timer.progress,
                phase: timer.phase,
                timeString: timer.timeString
            )
            .frame(width: 280, height: 280)

            // Controls
            HStack(spacing: 24) {
                Button(action: timer.reset) {
                    Label("Reset", systemImage: "arrow.counterclockwise")
                }
                .buttonStyle(.bordered)

                Button(action: timer.isRunning ? timer.pause : timer.start) {
                    Label(
                        timer.isRunning ? "Pause" : "Start",
                        systemImage: timer.isRunning ? "pause.fill" : "play.fill"
                    )
                    .frame(minWidth: 110)
                }
                .buttonStyle(.borderedProminent)
                .tint(timer.phase == .work ? .red : .green)
            }

            // Session count
            Label(
                "\(timer.completedPomodoros) pomodoros today",
                systemImage: "checkmark.seal.fill"
            )
            .font(.subheadline)
            .foregroundStyle(.secondary)

            Spacer()
        }
        .padding(.horizontal)
        .onAppear {
            NotificationManager.shared.requestAuthorisation()
        }
    }
}

#Preview {
    ContentView()
        .environment(PomodoroTimer())
}

5. Add the Privacy Manifest (required for App Store)

Apple requires a PrivacyInfo.xcprivacy file for any app using certain APIs. This app uses UserDefaults (an NSPrivacyAccessedAPICategoryUserDefaults required-reason API). Without the manifest, App Store validation will reject your build. Add the file to the project root, not inside a group folder, so Xcode picks it up automatically.

<!-- 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>NSPrivacyCollectedDataTypes</key>
    <array/>

    <key>NSPrivacyAccessedAPITypes</key>
    <array>
        <dict>
            <key>NSPrivacyAccessedAPIType</key>
            <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
            <key>NSPrivacyAccessedAPITypeReasons</key>
            <array>
                <!-- CA92.1: Access info from your own app only -->
                <string>CA92.1</string>
            </array>
        </dict>
    </array>

    <key>NSPrivacyTracking</key>
    <false/>
</dict>
</plist>

Common pitfalls

Adding monetization: One-time purchase

A one-time purchase is the cleanest model for a utility like this. Use StoreKit 2's Product.products(for:) to fetch a non-consumable IAP you've configured in App Store Connect (e.g. com.yourname.pomodoro.pro). Gate premium features — custom work/break durations, themes, or session history — behind a purchasedProductIDs set that you persist via Transaction.currentEntitlements on launch. Because StoreKit 2 is async/await-native you can write the entire purchase flow in under 40 lines without any receipt-validation server. Set the price point at $0.99–$2.99; productivity utilities with clean UI consistently convert at this tier.

Shipping this faster with Soarias

Soarias automates the parts of this project that take the most calendar time but add the least craft value. It scaffolds the full file structure above (including PrivacyInfo.xcprivacy pre-filled with the correct CA92.1 reason code), configures Fastlane with a working Matchfile and Fastfile for code signing, generates 6.7″ and 5.5″ App Store screenshots from your live simulator, fills in App Store Connect required metadata fields, and submits the build for review — all from a single command in your terminal.

For a beginner-complexity project like this Pomodoro Timer, most developers spend 4–6 hours on setup, signing, screenshot production, and ASC form-filling before a single line of business logic is reviewed. Soarias collapses that to under 30 minutes, which on a one-weekend timeline is the difference between shipping Saturday afternoon and giving up Sunday evening.

Related guides

FAQ

Does this work on iOS 16?

The @Observable macro and the #Preview macro both require iOS 17+. If you need iOS 16 support, replace @Observable with ObservableObject + @Published, pass the model via .environmentObject() instead of .environment(), and swap #Preview for PreviewProvider. Everything else in this guide is compatible with iOS 16.

Do I need a paid Apple Developer account to test?

No — you can sideload onto your own device for free using Xcode's personal team signing. However, free-team provisioning profiles expire every 7 days and you can only install on 3 devices. TestFlight and App Store submission require the $99/year Apple Developer Program membership.

How do I add this to the App Store?

In Xcode choose Product → Archive, then Distribute App → App Store Connect. Log in with your Apple ID, create the app record in App Store Connect (bundle ID, name, category), upload screenshots for at least the 6.7″ display size, fill in the privacy questionnaire, attach the build, and submit for review. First-time reviews typically take 24–48 hours.

My timer loses a few seconds every time I switch apps — how do I fix it?

This is the classic Foundation.Timer background-suspension problem. The fix: at the moment the user taps Start, record let endDate = Date().addingTimeInterval(Double(timeRemaining)). In your tick() function, compute timeRemaining = max(0, Int(endDate.timeIntervalSinceNow)) instead of decrementing. This is date-anchored, so it self-corrects instantly whether the app was backgrounded for 1 second or 10 minutes.

Last reviewed: 2026-05-12 by the Soarias team.

```