How to Build a Calorie Tracker App in SwiftUI
A calorie tracker lets users log meals, search a live nutrition database, and visualise daily intake against a personal goal — all stored locally on device. It's the definitive health-app portfolio project for iOS developers who want real HealthKit and Charts experience.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- Physical iPhone for HealthKit testing — the Simulator does not support HealthKit read/write
- HealthKit capability enabled in your App ID on the Apple Developer portal
Architecture overview
The data layer uses SwiftData to persist FoodEntry records in an on-device SQLite store; @Query and @Environment(\.modelContext) handle all state — no separate view models required for most screens. Nutrition search hits the Open Food Facts public JSON API at runtime and maps responses into transient NutritionItem value types before the user confirms a log entry. HealthKit writes confirmed calorie totals to the Active Energy store so Fitness and other apps see the data. The Charts framework renders per-meal bar charts and a daily progress gauge inline in the dashboard.
CalorieTracker/ ├── Models/ │ ├── FoodEntry.swift # SwiftData @Model │ └── NutritionItem.swift # Transient struct from API ├── Views/ │ ├── DashboardView.swift # Charts + daily summary │ ├── FoodLogListView.swift # Meal-grouped log │ └── SearchFoodView.swift # Open Food Facts search ├── Services/ │ └── HealthKitManager.swift # HKHealthStore write └── PrivacyInfo.xcprivacy
Step-by-step
1. Data model
Define FoodEntry as a SwiftData @Model so every logged item is automatically persisted to the on-device store with zero boilerplate.
import SwiftData
import Foundation
enum MealType: String, Codable, CaseIterable {
case breakfast, lunch, dinner, snack
}
@Model
final class FoodEntry {
var id: UUID = UUID()
var name: String
var calories: Double
var protein: Double
var carbs: Double
var fat: Double
var servingGrams: Double
var mealType: MealType
var loggedAt: Date = Date()
init(name: String, calories: Double, protein: Double,
carbs: Double, fat: Double,
servingGrams: Double = 100,
mealType: MealType = .lunch) {
self.name = name; self.calories = calories
self.protein = protein; self.carbs = carbs
self.fat = fat; self.servingGrams = servingGrams
self.mealType = mealType
}
}
2. Core UI — Dashboard
Render today's calorie total against the user's goal with a Gauge and a per-meal BarMark chart, both driven live by a filtered @Query.
import SwiftUI
import SwiftData
import Charts
struct DashboardView: View {
@Query(filter: #Predicate<FoodEntry> {
$0.loggedAt >= Calendar.current.startOfDay(for: .now)
}, sort: \FoodEntry.loggedAt) private var todayEntries: [FoodEntry]
let goal: Double = 2000
var total: Double { todayEntries.reduce(0) { $0 + $1.calories } }
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 28) {
Gauge(value: total, in: 0...goal) {
Text("kcal").font(.caption)
} currentValueLabel: {
Text(Int(total), format: .number).font(.title2.bold())
}
.gaugeStyle(.accessoryCircular)
.scaleEffect(2.4).frame(height: 150)
Chart(todayEntries) { entry in
BarMark(x: .value("Meal", entry.mealType.rawValue.capitalized),
y: .value("kcal", entry.calories))
.foregroundStyle(by: .value("Meal", entry.mealType.rawValue))
}
.frame(height: 180).padding(.horizontal)
}
.padding(.top, 24)
}
.navigationTitle("Today")
.toolbar {
ToolbarItem(placement: .primaryAction) {
NavigationLink(destination: SearchFoodView()) {
Image(systemName: "plus.circle.fill").font(.title2)
}
}
}
}
}
}
3. Food logging with nutrition database
Search Open Food Facts via its public JSON endpoint and insert a FoodEntry into the model context the moment the user confirms a result.
import SwiftUI
import SwiftData
struct SearchFoodView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State private var query = ""
@State private var results: [NutritionItem] = []
@State private var isLoading = false
var body: some View {
List(results) { item in
Button { logFood(item) } label: {
VStack(alignment: .leading, spacing: 2) {
Text(item.name).font(.headline)
Text("\(Int(item.kcalPer100g)) kcal · P \(Int(item.protein))g · C \(Int(item.carbs))g · F \(Int(item.fat))g")
.font(.caption).foregroundStyle(.secondary)
}
}
}
.searchable(text: $query, prompt: "Search foods…")
.task(id: query) {
guard query.count > 2 else { results = []; return }
isLoading = true
results = (try? await OpenFoodFactsService.search(query)) ?? []
isLoading = false
}
.overlay { if isLoading { ProgressView() } }
.navigationTitle("Add Food")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
private func logFood(_ item: NutritionItem) {
modelContext.insert(FoodEntry(name: item.name, calories: item.kcalPer100g,
protein: item.protein, carbs: item.carbs, fat: item.fat))
dismiss()
}
}
4. Privacy Manifest setup
Add PrivacyInfo.xcprivacy to your Xcode target declaring HealthKit data collection and UserDefaults access — both are required by App Store review.
<?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>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>CA92.1</string></array>
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeHealth</string>
<key>NSPrivacyCollectedDataTypeLinked</key><false/>
<key>NSPrivacyCollectedDataTypeTracking</key><false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array><string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string></array>
</dict>
</array>
</dict>
</plist>
Common pitfalls
- HealthKit on Simulator:
HKHealthStorereturns errors on the iOS Simulator. Keep a real device in your USB rotation from day one — do not leave HealthKit testing until just before submission. - Missing Info.plist purpose strings: Omitting
NSHealthUpdateUsageDescriptionorNSHealthShareUsageDescriptioncauses a silent crash on first launch. Add both keys even if you only write, not read. - Open Food Facts data gaps: Many products have null or zero nutrient fields. Always guard with
nutriments.energyKcal100g ?? 0and let users edit values before confirming — bad data is worse than missing data. - App Store review — HealthKit justification: Apple rejects apps that declare the HealthKit entitlement without a clear in-app use. Your screenshots and description must prominently show the calorie sync feature or reviewers will push back.
- SwiftData date predicate staleness:
#Predicateevaluated with an inlineDate()does not refire at midnight. StoreCalendar.current.startOfDay(for: .now)in a@Statevar and refresh it via.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)).
Adding monetization: Subscription
Use StoreKit 2's Product.products(for:) to fetch monthly and annual SKUs you've configured in App Store Connect. Gate premium features — custom macro goals, weekly trend charts, and CSV export — behind a check on Transaction.currentEntitlements at app launch, writing the result to @AppStorage("isPremium"). Surface the paywall as a .sheet when a free user taps a locked feature. Apple's subscription guidelines require the free tier to deliver genuine value, so keep basic calorie logging (without macro breakdown) fully free to avoid a guideline 3.1.2 rejection during review.
Shipping this faster with Soarias
Soarias scaffolds the full project in the first generation pass — SwiftData model, HealthKit entitlement pre-wired, and PrivacyInfo.xcprivacy with the correct health-data collection declarations already filled in. It then configures fastlane for automated screenshot capture across iPhone 16 Pro and iPhone SE, generates your App Store Connect listing (name, subtitle, keywords, privacy URL), and drives the final binary upload and submission via the ASC API — no web portal required.
For an intermediate project like this one, HealthKit entitlement setup, Privacy Manifest authoring, fastlane lane configuration, and ASC metadata entry typically consume 2–3 full days before you write a single line of product code. Soarias compresses that to under an hour, leaving your entire week for the parts that actually differentiate your app: food search UX, macro charts, and paywall design.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. A free account lets you sideload the app onto your own device via Xcode, but the HealthKit entitlement, TestFlight distribution, and App Store submission all require the $99/year Apple Developer Program membership.
How do I submit this to the App Store?
Archive in Xcode (Product → Archive) and upload via Xcode Organizer or xcrun altool. Then complete the App Store Connect listing — screenshots at required sizes, privacy nutrition labels declaring health data, subscription pricing tiers, and a privacy policy URL — before submitting for review. Health-category apps typically see a 24–48 hour review window.
Can I use a different nutrition database instead of Open Food Facts?
Yes. The USDA FoodData Central API is a free alternative with higher data quality for US products and requires only a free API key. Edamam and Nutritionix offer commercial APIs with more consistent international data — worth the cost if your subscription pricing can absorb it and your target audience is global.
Last reviewed: 2026-05-12 by the Soarias team.