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.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- A physical iPhone for WidgetKit testing — home screen widgets behave differently in the simulator and require a real device to verify timeline refresh
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
- Off-by-one at midnight. Using
timeIntervalSince / 86400breaks across DST boundaries. Always useCalendar.current.dateComponents([.day], ...)— it handles timezone changes correctly. - Widget showing stale count. Your
TimelineProvidermust setnextReloadDateto the following midnight, not a fixed interval like 60 minutes — otherwise the widget can lag by hours after device sleep. - App Group entitlement missing on one target. SwiftData through a shared container requires the App Group capability added to both the main app target and the widget extension. Missing it on one causes the widget to read empty data silently.
- App Store Review — avoid therapeutic claims. Reviewers flag sobriety apps that imply medical benefit (Guideline 5.1.3). Keep copy factual: "tracks days," not "supports your recovery program." This is one of the most common rejection reasons for this category.
- Privacy Manifest needed on the widget extension too. One
PrivacyInfo.xcprivacyin the app target does not cover the widget extension binary — each target that ships as a separate bundle needs its own file.
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.