How to Build a Focus Timer App in SwiftUI
A Focus Timer app helps users schedule deep work sessions with a Pomodoro-style countdown, rest breaks, and a session history chart. It's ideal for developers, students, or anyone who wants a distraction-free productivity tool on 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 permission testing — the simulator silently grants all permissions, which masks real behaviour
Architecture overview
The app uses a single @Observable TimerManager class that owns a Timer, drives the countdown, and writes completed FocusSession records into SwiftData. The main TimerView renders a trimmed circle for the progress ring and hands off to a StatsView that renders a Swift Charts bar chart over the last 7 days. There are no network calls or third-party dependencies.
FocusTimer/ ├── Models/ │ └── FocusSession.swift ← SwiftData @Model ├── Views/ │ ├── TimerView.swift ← countdown + controls │ └── StatsView.swift ← Charts history ├── TimerManager.swift ← @Observable state ├── FocusTimerApp.swift └── PrivacyInfo.xcprivacy
Step-by-step
1. Data model
Define a SwiftData @Model so each completed session is persisted and available for the history chart without manual Core Data setup.
import SwiftData
import Foundation
@Model
final class FocusSession {
var id: UUID
var startDate: Date
var duration: TimeInterval // planned seconds
var elapsed: TimeInterval // seconds completed
var label: String // "Work", "Short Break", "Long Break"
var completed: Bool
init(duration: TimeInterval, label: String = "Work") {
self.id = UUID()
self.startDate = Date()
self.duration = duration
self.elapsed = 0
self.label = label
self.completed = false
}
var progress: Double {
guard duration > 0 else { return 0 }
return min(elapsed / duration, 1.0)
}
}
2. Core UI — timer ring view
Render a circular progress ring using a trimmed Circle shape, keeping the animation tied to a single progress value so SwiftUI diffs it efficiently.
struct TimerView: View {
@Environment(\.modelContext) private var context
@State private var manager = TimerManager()
var body: some View {
VStack(spacing: 32) {
ZStack {
Circle()
.stroke(Color.secondary.opacity(0.2), lineWidth: 14)
Circle()
.trim(from: 0, to: manager.progress)
.stroke(Color.accentColor,
style: StrokeStyle(lineWidth: 14, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.linear(duration: 1), value: manager.progress)
VStack(spacing: 4) {
Text(manager.timeString)
.font(.system(size: 58, weight: .thin, design: .monospaced))
Text(manager.currentLabel)
.font(.subheadline).foregroundStyle(.secondary)
}
}
.frame(width: 260, height: 260)
HStack(spacing: 20) {
Button(manager.isRunning ? "Pause" : "Start") {
manager.toggle(context: context)
}
.buttonStyle(.borderedProminent).controlSize(.large)
Button("Reset") { manager.reset() }
.buttonStyle(.bordered).controlSize(.large)
}
}
.padding()
}
}
3. Deep work sessions — TimerManager
Implement the Pomodoro sequence in an @Observable class so all views react to state changes without any manual objectWillChange calls.
@Observable
final class TimerManager {
var remaining: TimeInterval = 25 * 60
var totalDuration: TimeInterval = 25 * 60
var isRunning = false
var currentLabel = "Work"
private var timer: Timer?
private var activeSession: FocusSession?
private var sessionCount = 0
var progress: Double { 1 - (remaining / totalDuration) }
var timeString: String {
let m = Int(remaining) / 60; let s = Int(remaining) % 60
return String(format: "%02d:%02d", m, s)
}
func toggle(context: ModelContext) {
isRunning ? pause() : start(context: context)
}
private func start(context: ModelContext) {
let s = FocusSession(duration: totalDuration, label: currentLabel)
context.insert(s); activeSession = s; isRunning = true
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
guard let self else { return }
remaining > 0 ? tick() : complete(context: context)
}
}
private func tick() {
remaining -= 1
activeSession?.elapsed = totalDuration - remaining
}
private func complete(context: ModelContext) {
timer?.invalidate(); isRunning = false
activeSession?.completed = true; activeSession = nil
sessionCount += 1
advancePhase()
}
private func advancePhase() {
if currentLabel == "Work" {
currentLabel = sessionCount % 4 == 0 ? "Long Break" : "Short Break"
totalDuration = sessionCount % 4 == 0 ? 15 * 60 : 5 * 60
} else {
currentLabel = "Work"; totalDuration = 25 * 60
}
remaining = totalDuration
}
private func pause() { timer?.invalidate(); isRunning = false }
func reset() { timer?.invalidate(); isRunning = false
remaining = totalDuration; activeSession = nil }
}
4. Privacy Manifest setup
Add PrivacyInfo.xcprivacy to your app target — required since Xcode 15 and enforced at App Store submission; missing it triggers an automatic rejection email.
<?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><string>CA92.1</string></array>
</dict>
</array>
</dict>
</plist>
Common pitfalls
- Background timer drift: iOS suspends
Timerwhen the app backgrounds. Store the session'sstartDateand recalculateelapsedfrom wall time onscenePhasechange to.active— don't trust tick counts. - Notification permission denial: Many users deny notifications on first launch. Always show a short explanation before calling
requestAuthorization, and degrade gracefully (show a banner inside the app) when denied. - App Store review — timer must actually work: Reviewers run the timer and let it reach zero. A crash or frozen ring at session end is a common rejection reason for timer apps — always test the full 25-minute cycle on device before submitting.
- SwiftData migration crashes: Adding a new property to
FocusSessionin an update without aMigrationPlanwill crash existing users on launch. Use a versioned schema from the start even if v1 has no migrations. - Simulator notification gap: The iOS Simulator grants notification permissions automatically but never delivers them. Run all notification-dependent tests on a physical device.
Adding monetization: One-time purchase
Implement a one-time unlock using StoreKit 2's Product.purchase() API. Define a single non-consumable in-app purchase in App Store Connect (e.g. com.yourapp.pro), then call Product.products(for:) at app launch to fetch it and Transaction.currentEntitlement(for:) to check if the user already owns it. Gate premium features — custom session lengths, additional themes, or extended stats history — behind a @AppStorage("isPro") flag you set only after a verified Transaction. Because it's a one-time purchase, there's no subscription to manage: no renewalInfo, no grace period logic, just purchase and verify. This is the simplest StoreKit 2 integration and well-suited for a beginner project.
Shipping this faster with Soarias
Soarias scaffolds the entire project from your app description — SwiftData model, TimerManager, privacy manifest, and fastlane lanes — in one shot. For a Focus Timer it generates the PrivacyInfo.xcprivacy with the correct UserDefaults reason code, wires up the .modelContainer in the app entry point, and configures a fastlane Deliverfile with your App Store Connect credentials so fastlane deliver handles screenshot upload and binary submission without you touching the ASC web UI.
For a beginner-complexity app like this, Soarias typically cuts the first-submission cycle from a full weekend to a few hours — the scaffolding, metadata entry, and fastlane setup that consume most of that time are fully automated. You spend your time on the actual timer logic, not plumbing.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes — the $99/year Apple Developer Program membership is required to distribute on TestFlight or the App Store. You can build and run on your own device with a free account, but you cannot share the app or submit it for review without a paid membership.
How do I submit this to the App Store?
Archive the app in Xcode (Product → Archive), then use the Organizer to upload to App Store Connect. In ASC, fill in the app description, screenshots for each device size, privacy labels, and your pricing, then submit the build for review. Expect a review window of 24–48 hours for a first submission.
How do I keep the timer accurate when the user locks their phone?
iOS will suspend your Timer when the screen locks. The reliable pattern is to save Date.now when the session starts (or when the app backgrounds via scenePhase), then on return to foreground subtract that saved date from the current time to recompute elapsed — never rely on cumulative tick counts alone.
Last reviewed: 2026-05-12 by the Soarias team.