How to Build a Quit Smoking App in SwiftUI
A Quit Smoking app tracks how long someone has been smoke-free, how much money they have saved, and how many cigarettes they have avoided — giving real-time motivation through visible numbers. It is ideal for anyone quitting cold-turkey or tapering, and for developers looking for a straightforward Health & Fitness debut on the App Store.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- No real device required for development, but test UserNotifications on a physical iPhone since the simulator suppresses some alert banners
Architecture overview
The data layer is a single SwiftData @Model (QuitSession) stored entirely on-device — no backend, no network calls. DashboardView owns a @Query that fetches the active session and pipes computed stats into reusable StatCard components. A Swift Charts AreaMark chart in SavingsChartView renders cumulative savings per day. UserNotifications fires milestone alerts at 1 day, 1 week, and 1 month using a simple local trigger registered on quit-date entry. State flows down via the shared ModelContainer inserted at app entry.
QuitSmokingApp/ ├── Models/ │ └── QuitSession.swift # @Model — quit date, cost inputs, computed stats ├── Views/ │ ├── DashboardView.swift # stat cards + savings chart │ ├── SetupView.swift # onboarding form (quit date, cost) │ └── MilestonesView.swift # badge grid (1d / 1w / 1m / 1y) ├── Components/ │ └── StatCard.swift # reusable metric tile └── PrivacyInfo.xcprivacy
Step-by-step
1. Data model
Define a QuitSession SwiftData model that stores user inputs and exposes smoke-free stats as computed properties so views always read fresh values.
import SwiftData
import Foundation
@Model
final class QuitSession {
var quitDate: Date
var cigarettesPerDay: Int
var costPerPack: Double
var cigarettesPerPack: Int
init(quitDate: Date = .now, cigarettesPerDay: Int = 20,
costPerPack: Double = 12.0, cigarettesPerPack: Int = 20) {
self.quitDate = quitDate
self.cigarettesPerDay = cigarettesPerDay
self.costPerPack = costPerPack
self.cigarettesPerPack = cigarettesPerPack
}
var daysSmokeFree: Int {
Calendar.current.dateComponents([.day], from: quitDate, to: .now).day ?? 0
}
var moneySaved: Double {
let packsPerDay = Double(cigarettesPerDay) / Double(cigarettesPerPack)
return packsPerDay * costPerPack * Double(daysSmokeFree)
}
var cigarettesAvoided: Int { daysSmokeFree * cigarettesPerDay }
}
2. Core UI — dashboard view
Query the active session with @Query and render three stat cards; if no session exists yet, nudge the user to set their quit date.
struct DashboardView: View {
@Query private var sessions: [QuitSession]
@State private var showSetup = false
private var session: QuitSession? { sessions.first }
var body: some View {
NavigationStack {
if let session {
ScrollView {
VStack(spacing: 20) {
StatCard("Days Smoke-Free",
value: "\(session.daysSmokeFree)",
icon: "lungs.fill", tint: .green)
StatCard("Money Saved",
value: session.moneySaved
.formatted(.currency(code: "USD")),
icon: "dollarsign.circle.fill", tint: .mint)
StatCard("Cigarettes Avoided",
value: "\(session.cigarettesAvoided)",
icon: "nosign", tint: .orange)
SavingsChartView(session: session)
}
.padding()
}
.navigationTitle("My Journey")
} else {
ContentUnavailableView("Start Your Journey",
systemImage: "heart.fill",
description: Text("Tap to set your quit date."))
.onTapGesture { showSetup = true }
}
}
.sheet(isPresented: $showSetup) { SetupView() }
}
}
3. Progress and savings tracker
Render cumulative savings as a Swift Charts AreaMark, giving users a visual reward curve that grows steeper over time.
import Charts
struct SavingsChartView: View {
let session: QuitSession
private var data: [(day: Int, saved: Double)] {
let days = max(session.daysSmokeFree, 1)
let packsPerDay = Double(session.cigarettesPerDay) /
Double(session.cigarettesPerPack)
return (0...days).map { d in
(day: d, saved: packsPerDay * session.costPerPack * Double(d))
}
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Cumulative Savings").font(.headline)
Chart(data, id: \.day) { point in
AreaMark(
x: .value("Day", point.day),
y: .value("Saved", point.saved)
)
.foregroundStyle(.green.gradient)
LineMark(
x: .value("Day", point.day),
y: .value("Saved", point.saved)
)
.foregroundStyle(.green)
}
.chartXAxisLabel("Days smoke-free")
.chartYAxisLabel("USD")
.frame(height: 160)
}
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
}
}
4. Privacy Manifest setup
Add PrivacyInfo.xcprivacy to your Xcode target (File → New → File → Privacy Manifest) and declare the UserDefaults API type that SwiftData and SwiftUI access internally — App Store Connect will reject your build without it.
<?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>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTrackingDomains</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
- Medical language triggers App Store rejection. Avoid "clinically proven," "cure," or "treat nicotine addiction." Stick to "track your progress" and "see how much you've saved." Reviewers enforce App Store Guideline 5.1.3 on health claims strictly.
- Raw
TimeIntervalmath breaks across DST boundaries. Never subtract dates directly for day counts. Always useCalendar.current.dateComponents([.day], from:to:)— it handles daylight saving shifts correctly. - Scheduling notifications after the milestone has passed. Register
UNCalendarNotificationTriggerat quit-date entry, not lazily on app launch days later. If the user quits and the 24-hour window passes before you register, the alert never fires. - Widget extension can't see the SwiftData store without an App Group. If you add a home-screen widget, configure the
ModelContainerin both targets with a shared App Group URL; the default container path is per-app and not accessible cross-target. - Missing Privacy Manifest blocks App Store upload entirely. Xcode 16 will warn you, but the upload pipeline rejects the binary if the manifest is absent or lists wrong reason codes for accessed APIs.
Adding monetization: Subscription
Gate premium features — extended chart history beyond 7 days, milestone badge unlocks, and daily motivational notifications — behind a StoreKit 2 auto-renewable subscription. Use Product.products(for: ["com.yourapp.premium.monthly"]) to fetch the product, product.purchase() to start checkout, and Transaction.currentEntitlement(for:) at app launch to verify the user's active entitlement without a server. Keep a free tier that shows only the last 7 days of savings history — it keeps the conversion funnel open and avoids the perception that core quit-tracking is paywalled, which can attract negative App Store reviews.
Shipping this faster with Soarias
Soarias scaffolds the QuitSession SwiftData model, DashboardView, StatCard component, and Swift Charts integration from a plain-English prompt in a single shot. It writes PrivacyInfo.xcprivacy with the correct NSPrivacyAccessedAPITypeReasons codes automatically, configures fastlane deliver with your screenshots and App Store metadata, and handles the App Store Connect binary upload — so you never touch the manual upload form.
For a beginner-complexity app like this one, most first-time developers spend 2–4 hours on Xcode project setup, Privacy Manifest research, and App Store Connect configuration before writing a single line of product code. Soarias compresses that overhead to under 15 minutes, leaving your full 1–2 weekend budget for features, UI polish, and testing on a real device.
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 a simulator for free, but real-device testing of UserNotifications and App Store submission both require an active membership.
How do I submit this to the App Store?
Archive your build in Xcode (Product → Archive), then use the Organizer to distribute it to App Store Connect. You'll need an app record created in App Store Connect first — set the bundle ID, name, category (Health & Fitness), and age rating (4+) before uploading. After upload, attach the build to a new app version and submit for review.
Can users track multiple quit attempts?
Yes — SwiftData supports multiple QuitSession objects. Add a list view that lets users create a new session (e.g. after a relapse) and switch between attempt histories. Use a @Query(sort: \.quitDate, order: .reverse) to always surface the most recent attempt first on the dashboard.
Last reviewed: 2026-05-12 by the Soarias team.