How to Build a Sobriety Counter App in SwiftUI

A sobriety counter tracks days free from a substance, celebrates milestones, and surfaces the streak on the home screen via a widget — it's one of the most personal utilities in the App Store. This guide is for iOS developers who want to ship a clean, local-first counter with SwiftData and WidgetKit in a weekend.

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

Prerequisites

Architecture overview

The app uses a single SwiftData SobrietyRecord model to persist the start date and substance label locally — no server, no sign-in. The view layer is three files: the main counter screen, an animated ring component, and a start-date entry sheet. A WidgetKit extension reads from the same App Group container so the home screen widget reflects the live count without any extra work. State flows down from @Query; nothing is passed up.

SobrietyCounterApp/
├── Models/
│   └── SobrietyRecord.swift      # @Model, daysSober + nextMilestone
├── Views/
│   ├── CounterView.swift          # Root screen, @Query
│   ├── DaysCircleView.swift       # Animated arc + numericText
│   └── AddRecordSheet.swift       # DatePicker sheet
├── Widget/
│   └── SobrietyWidget.swift       # WidgetKit TimelineProvider
└── PrivacyInfo.xcprivacy          # Required for both targets

Step-by-step

1. Data model

Define a SwiftData @Model that stores the start date and exposes daysSober via Calendar.current — using Calendar (not raw seconds) means DST transitions never cause an off-by-one.

import SwiftData
import Foundation

@Model
final class SobrietyRecord {
    var startDate: Date
    var substanceName: String
    var notes: String

    init(startDate: Date, substanceName: String = "Alcohol", notes: String = "") {
        self.startDate = startDate
        self.substanceName = substanceName
        self.notes = notes
    }

    var daysSober: Int {
        Calendar.current.dateComponents([.day], from: startDate, to: .now).day ?? 0
    }

    var nextMilestone: Int {
        let milestones = [1, 7, 14, 30, 60, 90, 180, 365, 730]
        return milestones.first { $0 > daysSober } ?? daysSober + 365
    }
}

2. Core UI

Build CounterView as the root screen — it queries SwiftData directly with @Query, shows an empty state with ContentUnavailableView when no record exists, and opens an entry sheet from the toolbar.

struct CounterView: View {
    @Query private var records: [SobrietyRecord]
    @State private var showingAddSheet = false

    var body: some View {
        NavigationStack {
            Group {
                if let record = records.first {
                    VStack(spacing: 28) {
                        DaysCircleView(days: record.daysSober)
                        Text(record.substanceName)
                            .font(.headline).foregroundStyle(.secondary)
                        MilestonesRowView(daysSober: record.daysSober)
                    }.padding()
                } else {
                    ContentUnavailableView("Start Your Journey",
                        systemImage: "star.circle",
                        description: Text("Tap + to log your sobriety start date."))
                }
            }
            .navigationTitle("Sobriety Counter")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("Add", systemImage: "plus") { showingAddSheet = true }
                }
            }
            .sheet(isPresented: $showingAddSheet) { AddRecordSheet() }
        }
    }
}

3. Days sober counter with animation

The core feature is DaysCircleView — a trimmed Circle arc that fills toward the next milestone, paired with iOS 17's .contentTransition(.numericText()) for the rolling number flip on first appear.

struct DaysCircleView: View {
    let days: Int
    @State private var animatedDays = 0

    var body: some View {
        ZStack {
            Circle()
                .stroke(Color.accentColor.opacity(0.15), lineWidth: 14)
                .frame(width: 220, height: 220)
            Circle()
                .trim(from: 0, to: progress)
                .stroke(Color.accentColor,
                        style: StrokeStyle(lineWidth: 14, lineCap: .round))
                .frame(width: 220, height: 220)
                .rotationEffect(.degrees(-90))
                .animation(.easeInOut(duration: 1.2), value: progress)
            VStack(spacing: 4) {
                Text("\(animatedDays)")
                    .font(.system(size: 64, weight: .bold, design: .rounded))
                    .contentTransition(.numericText())
                Text("days sober").font(.subheadline).foregroundStyle(.secondary)
            }
        }
        .onAppear { withAnimation(.easeOut(duration: 0.9)) { animatedDays = days } }
    }

    private var progress: CGFloat { min(CGFloat(days) / 90.0, 1.0) }
}

4. Privacy Manifest setup

Apps that access UserDefaults — which WidgetKit uses internally — must declare it in a Privacy Manifest; without one your binary will be rejected at upload with a missing required reason error.

<!-- PrivacyInfo.xcprivacy — add to BOTH app target and widget extension -->
<?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

Use StoreKit 2's Product.purchase() API to gate premium features — multiple substance trackers, custom milestone labels, and widget color themes — behind a single unlock. Persist the unlocked state with @AppStorage("isPremium") and verify entitlement on launch via Transaction.currentEntitlements. Call AppStore.sync() on first launch to restore purchases after a reinstall. Because this is a one-time purchase — not a subscription — there is no renewal logic, no grace period handling, and no server required, which keeps the StoreKit surface area minimal and well within reach for a beginner project.

Shipping this faster with Soarias

Soarias scaffolds the full Xcode project from a prompt — SwiftData schema, WidgetKit extension wired to an App Group, App Group entitlements on both targets, and a PrivacyInfo.xcprivacy for each binary — in about a minute. It also configures fastlane, captures App Store screenshots across every required device size automatically, fills in App Store Connect metadata, and submits the binary without you opening Organizer.

For a beginner project at this complexity level, the non-coding overhead — provisioning profiles, App Group setup, screenshot resizing, Privacy Manifest for two targets, and the ASC metadata form — typically consumes three to four hours on a first submission. Soarias compresses that to around 20 minutes, so a developer who finishes the SwiftUI code on Saturday morning can have a build in TestFlight by Saturday afternoon.

Related guides

FAQ

Do I need a paid Apple Developer account?

Yes. The free tier lets you sideload the app onto your own device via Xcode, but TestFlight distribution, WidgetKit on device, and App Store submission all require an active $99/year Apple Developer Program membership.

How do I submit this to the App Store?

Archive the app in Xcode via Product → Archive, validate the binary, then upload to App Store Connect. Fill in the metadata, upload screenshots for all required device sizes (iPhone 16 Pro Max and iPhone SE at minimum), configure your one-time purchase in-app product, and submit for review. First-time reviews typically take 24–48 hours. Soarias handles the upload, screenshots, and metadata steps automatically.

Can users track multiple substances at once?

Yes — remove the records.first filter and render each record in a List or a TabView. This makes a natural premium feature: the free tier tracks one substance, and the one-time purchase unlocks unlimited entries.

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