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

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.

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

Prerequisites

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

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.

```