How to Build a Fasting Tracker App in SwiftUI

A Fasting Tracker app lets users start an intermittent fasting timer, watch a live countdown ring, and receive a local push notification the moment their goal is reached — all stored locally so no account or server is ever needed. It's a great first health app for indie developers: focused scope, a clear monetization path, and zero external API dependencies.

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

Prerequisites

Architecture overview

The app is a straightforward MVVM stack. A FastingSession SwiftData model handles persistence, an @Observable view model drives the live countdown and schedules local notifications, and two tab views — a timer screen and a history list — cover the entire surface area. There is no networking, no account system, and no third-party SDK required to ship v1.

FastingTrackerApp/
├── FastingTrackerApp.swift        # @main + .modelContainer(for: FastingSession.self)
├── Models/
│   └── FastingSession.swift      # @Model: startDate, targetDuration, endDate, completed
├── ViewModels/
│   └── FastingTimer.swift        # @Observable: tick, progress, notification scheduling
├── Views/
│   ├── ContentView.swift         # TabView root (Timer | History)
│   ├── TimerView.swift           # Circular ring + Start/End button
│   └── HistoryView.swift         # @Query list of past sessions
├── Services/
│   └── NotificationService.swift # UNUserNotificationCenter request helpers
└── PrivacyInfo.xcprivacy         # Required for App Store — declare UserDefaults usage
        

Step-by-step

1. Project setup

In Xcode, choose File → New → Project, select the App template, set the interface to SwiftUI, and check Use SwiftData. Delete the auto-generated Item model — you will replace it with FastingSession. The entry point below attaches the model container to the entire view hierarchy so every child view can query and mutate sessions through the environment.

// FastingTrackerApp.swift
import SwiftUI
import SwiftData

@main
struct FastingTrackerApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: FastingSession.self)
    }
}

2. Data model

The FastingSession model stores everything needed to reconstruct a session after the app is killed and relaunched: when it started, what the goal duration was, when it ended, and whether it hit the target. Keeping computed properties on the model — elapsed and progress — means views never contain raw date arithmetic.

// Models/FastingSession.swift
import SwiftData
import Foundation

@Model
final class FastingSession {
    var startDate: Date
    var targetDuration: TimeInterval   // seconds — 57_600 = 16 h
    var endDate: Date?
    var completed: Bool

    init(
        startDate: Date = .now,
        targetDuration: TimeInterval = 57_600
    ) {
        self.startDate = startDate
        self.targetDuration = targetDuration
        self.endDate = nil
        self.completed = false
    }

    /// Real elapsed time, computed from the clock — not a tick counter.
    var elapsed: TimeInterval {
        (endDate ?? .now).timeIntervalSince(startDate)
    }

    var progress: Double {
        min(elapsed / targetDuration, 1.0)
    }

    var durationHours: Int {
        Int(targetDuration / 3600)
    }
}

3. Core UI — circular timer view

Keep TimerView thin: it reads state from the FastingTimer view model and delegates every action to it. The circular ring is a trimmed Circle rotated 90 degrees so progress starts at the top. A TabView root makes it easy to add a history tab without restructuring the navigation tree later.

// Views/ContentView.swift
import SwiftUI

struct ContentView: View {
    var body: some View {
        TabView {
            TimerView()
                .tabItem { Label("Fast", systemImage: "timer") }
            HistoryView()
                .tabItem { Label("History", systemImage: "list.bullet") }
        }
    }
}

// Views/TimerView.swift
import SwiftUI
import SwiftData

struct TimerView: View {
    @Environment(\.modelContext) private var context
    @State private var timerVM = FastingTimer()

    var body: some View {
        NavigationStack {
            VStack(spacing: 32) {
                Text(timerVM.isActive ? "Currently Fasting" : "Ready to Fast")
                    .font(.title2.weight(.semibold))
                    .foregroundStyle(timerVM.isActive ? .green : .secondary)

                // Circular progress ring
                ZStack {
                    Circle()
                        .stroke(Color.secondary.opacity(0.15), lineWidth: 14)
                    Circle()
                        .trim(from: 0, to: timerVM.progress)
                        .stroke(
                            timerVM.progress >= 1 ? Color.yellow : Color.green,
                            style: StrokeStyle(lineWidth: 14, lineCap: .round)
                        )
                        .rotationEffect(.degrees(-90))
                        .animation(.linear(duration: 1), value: timerVM.progress)

                    VStack(spacing: 6) {
                        Text(timerVM.timeString)
                            .font(.system(size: 46, weight: .bold, design: .monospaced))
                        Text("of \(timerVM.targetLabel)")
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                    }
                }
                .frame(width: 270, height: 270)

                Button(timerVM.isActive ? "End Fast" : "Start Fast") {
                    if timerVM.isActive {
                        timerVM.stop(context: context)
                    } else {
                        timerVM.start(context: context)
                    }
                }
                .buttonStyle(.borderedProminent)
                .tint(timerVM.isActive ? .red : .green)
                .font(.headline)
                .controlSize(.large)
            }
            .padding()
            .navigationTitle("Fasting Tracker")
        }
    }
}

#Preview {
    TimerView()
        .modelContainer(for: FastingSession.self, inMemory: true)
}

4. Core feature — fasting timer logic and local notifications

The @Observable FastingTimer class owns a one-second Timer, persists the active session, and schedules a UNTimeIntervalNotificationTrigger so users get an alert even when the app is backgrounded. Crucially, elapsed is always derived from the persisted startDate rather than a tick counter — this means the displayed time stays accurate even if iOS suspends and restarts the app mid-fast.

// ViewModels/FastingTimer.swift
import SwiftUI
import SwiftData
import UserNotifications

@Observable
final class FastingTimer {
    var elapsed: TimeInterval = 0
    var targetDuration: TimeInterval = 57_600   // 16 h default
    var isActive: Bool = false

    private var ticker: Timer?
    private var sessionID: PersistentIdentifier?

    // MARK: - Derived state

    var progress: Double {
        isActive ? min(elapsed / targetDuration, 1.0) : 0
    }

    var timeString: String {
        let t = Int(elapsed)
        return String(format: "%02d:%02d:%02d",
                      t / 3600, (t % 3600) / 60, t % 60)
    }

    var targetLabel: String {
        "\(Int(targetDuration / 3600))h fast"
    }

    // MARK: - Controls

    func start(context: ModelContext) {
        let session = FastingSession(startDate: .now,
                                     targetDuration: targetDuration)
        context.insert(session)
        try? context.save()
        sessionID = session.persistentModelID
        elapsed = 0
        isActive = true

        // Tick every second; derive elapsed from real clock to survive suspension.
        let start = session.startDate
        ticker = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
            guard let self else { return }
            self.elapsed = Date.now.timeIntervalSince(start)
            if self.elapsed >= self.targetDuration {
                self.finish(context: context, completed: true)
            }
        }

        scheduleNotification()
    }

    func stop(context: ModelContext) {
        finish(context: context, completed: elapsed >= targetDuration)
        cancelNotification()
    }

    // MARK: - Private helpers

    private func finish(context: ModelContext, completed: Bool) {
        ticker?.invalidate()
        ticker = nil
        if let id = sessionID,
           let session = context.model(for: id) as? FastingSession {
            session.endDate = .now
            session.completed = completed
            try? context.save()
        }
        isActive = false
    }

    private func scheduleNotification() {
        let remaining = max(targetDuration - elapsed, 1)
        UNUserNotificationCenter.current()
            .requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
                guard granted else { return }
                let content = UNMutableNotificationContent()
                content.title = "Fast complete! 🎉"
                content.body = "You finished your \(Int(self.targetDuration / 3600))-hour fast. Time to break the fast."
                content.sound = .default
                let trigger = UNTimeIntervalNotificationTrigger(
                    timeInterval: remaining,
                    repeats: false
                )
                let request = UNNotificationRequest(
                    identifier: "fast-complete",
                    content: content,
                    trigger: trigger
                )
                UNUserNotificationCenter.current().add(request)
            }
    }

    private func cancelNotification() {
        UNUserNotificationCenter.current()
            .removePendingNotificationRequests(withIdentifiers: ["fast-complete"])
    }
}

5. Privacy Manifest

Apple requires a PrivacyInfo.xcprivacy file for any app that touches specific system APIs — including UserDefaults, which SwiftUI and SwiftData use internally. Submitting without it triggers an ITMS-91053 rejection from App Store Connect. In Xcode, go to File → New → File → App Privacy and declare each API type. For a basic fasting tracker, reason code CA92.1 covers first-party UserDefaults access — no data is shared with any third party.

<!-- 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>
                <string>CA92.1</string>
            </array>
        </dict>
    </array>
</dict>
</plist>

Common pitfalls

Adding monetization: Subscription

The cleanest paywall for a fasting tracker is freemium gated on history depth and analytics. Keep the free tier limited to the current active fast and the last 7 sessions; unlock unlimited history, custom fasting presets (12h, 18h, 24h, OMAD), and a Charts-powered weekly streak view behind a monthly or annual subscription. Configure your product IDs in App Store Connect first — for example, com.yourapp.fasting.monthly and com.yourapp.fasting.annual — then use StoreKit 2's SubscriptionStoreView (iOS 17+) to render a native paywall with a single view declaration, no custom UI required. Call Transaction.currentEntitlements to check active entitlements before rendering locked content, and always catch StoreKitError.userCancelled silently so a tap on "Cancel" never surfaces an error alert to the user.

Shipping this faster with Soarias

Soarias scaffolds the complete Xcode project from a short prompt — FastingSession model, @Observable view model, TabView root, and a working circular-ring countdown — in under a minute. It also generates the PrivacyInfo.xcprivacy file with the correct API types and reason codes, writes a Fastfile for one-command TestFlight uploads, and pre-fills your App Store Connect metadata fields (name, subtitle, keywords, promotional text) so you are not staring at a blank form the night before launch.

For a beginner-level app like this one, Soarias typically compresses the two-weekend estimate to a single afternoon: roughly 30 minutes from first prompt to a running simulator build, another 30 minutes to customise colors and copy, and one fastlane command to push the first build to TestFlight. The $79 one-time price pays for itself the first weekend you avoid a manual provisioning-profile or Privacy Manifest fight.

Related guides

FAQ

Does this work on iOS 16?

No. Both SwiftData and the @Observable macro require iOS 17 as the minimum deployment target. If you need iOS 16 support, you can swap SwiftData for Core Data and replace @Observable with @ObservableObject — but the migration is non-trivial, and iOS 16's install base is small enough that most new apps ship iOS 17-only from day one.

Do I need a paid Apple Developer account to test?

You can build and run on the iOS Simulator for free with any Apple ID. Sideloading to a physical device is also free but limited to a handful of apps at a time and requires re-signing every seven days. A paid Apple Developer Program membership ($99/year) is required for TestFlight distribution and App Store submission — there is no workaround for public distribution.

How do I add this to the App Store?

Create an app record in App Store Connect, then in Xcode choose Product → Archive, validate the build, and distribute to TestFlight. You will need at least one screenshot per device size class (Xcode Simulator works), a privacy policy URL (required whenever you use UserNotifications), and completed privacy nutrition labels. Once TestFlight testing is done, promote the same build to App Store review from the App Store Connect dashboard. Soarias automates the screenshot capture, metadata entry, and fastlane submission command into a single flow.

How do I keep the timer accurate when the app is backgrounded?

Never use a tick counter as the source of truth for elapsed time. iOS will suspend your app and the counter will stop. Instead, save the startDate to SwiftData immediately when the fast begins and compute elapsed = Date.now.timeIntervalSince(startDate) each time the scene becomes active. The live Timer is only for refreshing the UI — the displayed time is always derived from the real clock, not the tick count. This pattern is exactly what the code in Step 4 above demonstrates.

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