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.
Prerequisites
- Mac running macOS Sequoia or later with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store distribution
- Basic Swift and SwiftUI familiarity (views, state, modifiers)
- A physical iPhone or iPad for notification testing — the Simulator cannot deliver push/local notifications reliably on all Xcode versions
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
-
Timer drift on the main thread. A
Foundation.Timerscheduled on the default run loop pauses when the main thread is busy (e.g. during a scroll). Add the timer toRunLoop.mainwith.commonmode, or better yet record the target end-date withDate()at start and compute remaining seconds on each tick — that way one dropped tick doesn't accumulate error. -
Notifications never arrive on device. You must request authorisation before scheduling any
UNNotificationRequest. CallrequestAuthorisation()in.onAppearon first launch; if the user denies it and you silently ignore the error, the start button will appear to work but nothing fires. -
Background countdown continues in the app, but the timer stops. iOS suspends your
Foundation.Timerwhen the app backgrounds. Rely on the scheduledUNTimeIntervalNotificationTriggerfor the end signal, and on foreground restore compute elapsed time withDate()instead of trusting tick count. -
App Store rejection: missing NSUserNotificationsUsageDescription. Add a plain-English string to
Info.plistexplaining why the app needs notifications. Reviewers flag vague strings like "for app use" — be specific: "To alert you when each focus or break session ends." - Missing Privacy Manifest causes automated rejection. If you include any third-party SDK (analytics, crash reporting) that itself uses required-reason APIs, you must also include their manifests. Forgetting this is the single most common post-WWDC 2024 rejection reason for new submissions.
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.