How to Build a Mood Tracker App in SwiftUI
A Mood Tracker app lets users log how they feel each day and visualise patterns over time with a line chart. It is ideal for indie developers who want a meaningful, habit-forming product they can ship in a weekend.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge: structs, property wrappers, and trailing closures
- A real iPhone or iPad for notification testing —
UNCalendarNotificationTriggeralerts are unreliable in the simulator when the app is foregrounded
Architecture overview
The app uses a single MoodEntry SwiftData model stored in a local container — no server required. Two SwiftUI views consume the model context: HistoryView lists past entries and hosts a sheet to LogMoodView, while TrendsView queries the same data and feeds it into a Swift Charts line graph scoped to the last 30 days. A lightweight ReminderManager singleton wraps UNUserNotificationCenter to schedule a nightly nudge. State flows entirely through @Query and @Environment(\.modelContext) — no additional observable objects are needed.
MoodTrackerApp/
├── MoodTrackerApp.swift # @main · .modelContainer(for: MoodEntry.self)
├── Models/
│ └── MoodEntry.swift # @Model: date, mood (Int 1–5), note (String)
├── Views/
│ ├── ContentView.swift # TabView root — Log tab + Trends tab
│ ├── HistoryView.swift # @Query list · sheet → LogMoodView
│ ├── LogMoodView.swift # Emoji picker · TextField · save action
│ └── TrendsView.swift # Swift Charts LineMark · 30-day window
├── Notifications/
│ └── ReminderManager.swift # UNUserNotificationCenter wrapper
└── PrivacyInfo.xcprivacy # Required for App Store submission
Step-by-step
1. Set up your Xcode project with SwiftData
In Xcode 16, choose File › New › Project, pick the iOS App template, and tick Use SwiftData. Open the generated app file and confirm the .modelContainer modifier is attached to the WindowGroup — this makes the persistent store available to every view in the hierarchy via @Environment(\.modelContext).
// MoodTrackerApp.swift
import SwiftUI
import SwiftData
@main
struct MoodTrackerApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: MoodEntry.self)
}
}
// ContentView.swift — TabView root
import SwiftUI
struct ContentView: View {
var body: some View {
TabView {
HistoryView()
.tabItem { Label("Log", systemImage: "face.smiling") }
TrendsView()
.tabItem { Label("Trends", systemImage: "chart.line.uptrend.xyaxis") }
}
}
}
#Preview {
ContentView()
.modelContainer(for: MoodEntry.self, inMemory: true)
}
2. Define the MoodEntry data model
Annotating a final class with @Model is all SwiftData needs to generate the underlying schema and handle persistence. Storing mood as an Int from 1–5 makes chart axis scaling straightforward and avoids string comparisons later. Computed helpers for emoji and label live in an extension so the model stays clean.
// Models/MoodEntry.swift
import SwiftData
import Foundation
@Model
final class MoodEntry {
var date: Date
var mood: Int // 1 (very bad) … 5 (great)
var note: String
init(date: Date = .now, mood: Int, note: String = "") {
self.date = date
self.mood = mood
self.note = note
}
}
extension MoodEntry {
static let moodEmojis = ["😞", "😕", "😐", "🙂", "😄"]
static let moodLabels = ["Very Bad", "Bad", "Okay", "Good", "Great"]
var emoji: String {
Self.moodEmojis[max(0, min(mood - 1, 4))]
}
var label: String {
Self.moodLabels[max(0, min(mood - 1, 4))]
}
}
3. Build the mood logging UI
The history list reads entries via @Query sorted newest-first and presents a sheet for logging. LogMoodView exposes an emoji row picker — tapping a face updates selectedMood and a coloured ring gives clear selection feedback. Saving inserts a new model object and dismisses the sheet; SwiftData propagates the change to all @Query subscribers automatically.
// Views/HistoryView.swift
import SwiftUI
import SwiftData
struct HistoryView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \MoodEntry.date, order: .reverse) private var entries: [MoodEntry]
@State private var showLog = false
var body: some View {
NavigationStack {
List {
ForEach(entries) { entry in
HStack {
Text(entry.emoji).font(.title2)
VStack(alignment: .leading, spacing: 2) {
Text(entry.label)
.font(.subheadline).bold()
if !entry.note.isEmpty {
Text(entry.note)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
Text(entry.date.formatted(date: .abbreviated, time: .omitted))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.onDelete { offsets in
offsets.map { entries[$0] }.forEach(modelContext.delete)
}
}
.navigationTitle("Mood Journal")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Log Mood", systemImage: "plus") {
showLog = true
}
}
}
.sheet(isPresented: $showLog) {
LogMoodView()
}
}
}
}
// Views/LogMoodView.swift
import SwiftUI
import SwiftData
struct LogMoodView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State private var selectedMood = 3
@State private var note = ""
var body: some View {
NavigationStack {
Form {
Section("How are you feeling?") {
HStack(spacing: 12) {
ForEach(1...5, id: \.self) { value in
Button {
selectedMood = value
} label: {
Text(MoodEntry.moodEmojis[value - 1])
.font(.largeTitle)
.padding(6)
.background(
selectedMood == value
? Color.accentColor.opacity(0.15)
: Color.clear
)
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(
selectedMood == value
? Color.accentColor
: Color.clear,
lineWidth: 2
)
)
}
.buttonStyle(.plain)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
}
Section("Note (optional)") {
TextField("What's on your mind?", text: $note, axis: .vertical)
.lineLimit(3...6)
}
}
.navigationTitle("Log Mood")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel", role: .cancel) { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
let entry = MoodEntry(mood: selectedMood, note: note)
modelContext.insert(entry)
dismiss()
}
}
}
}
}
}
#Preview {
LogMoodView()
.modelContainer(for: MoodEntry.self, inMemory: true)
}
4. Display mood trends with Swift Charts
Filter the last 30 days of entries and feed them into a Chart view using both LineMark and PointMark — the point marks make sparse data readable when a user only logs a few times per week. Pin .chartYScale(domain: 1...5) so the axis never auto-scales in a misleading way, and swap the default tick labels for mood emojis via AxisValueLabel.
// Views/TrendsView.swift
import SwiftUI
import Charts
import SwiftData
struct TrendsView: View {
@Query(sort: \MoodEntry.date) private var entries: [MoodEntry]
private var recentEntries: [MoodEntry] {
let cutoff = Calendar.current.date(
byAdding: .day, value: -30, to: .now
) ?? .now
return entries.filter { $0.date >= cutoff }
}
private var averageMood: Double? {
guard !recentEntries.isEmpty else { return nil }
let sum = recentEntries.reduce(0) { $0 + $1.mood }
return Double(sum) / Double(recentEntries.count)
}
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
if recentEntries.isEmpty {
ContentUnavailableView(
"No entries yet",
systemImage: "chart.line.uptrend.xyaxis",
description: Text("Log your first mood to see your trends here.")
)
.padding(.top, 60)
} else {
if let avg = averageMood {
HStack {
VStack(alignment: .leading) {
Text("30-day average")
.font(.caption)
.foregroundStyle(.secondary)
Text(String(format: "%.1f / 5.0", avg))
.font(.title2.bold())
}
Spacer()
Text(avg >= 3.5 ? "😊" : avg >= 2.5 ? "😐" : "😕")
.font(.system(size: 44))
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal)
}
Chart(recentEntries) { entry in
LineMark(
x: .value("Date", entry.date, unit: .day),
y: .value("Mood", entry.mood)
)
.foregroundStyle(.blue)
.interpolationMethod(.catmullRom)
PointMark(
x: .value("Date", entry.date, unit: .day),
y: .value("Mood", entry.mood)
)
.foregroundStyle(.blue)
.symbolSize(60)
}
.chartYScale(domain: 1...5)
.chartYAxis {
AxisMarks(values: [1, 2, 3, 4, 5]) { value in
AxisValueLabel {
if let intVal = value.as(Int.self) {
Text(MoodEntry.moodEmojis[intVal - 1])
}
}
AxisGridLine()
}
}
.frame(height: 240)
.padding()
}
}
}
.navigationTitle("Trends")
}
}
}
#Preview {
TrendsView()
.modelContainer(for: MoodEntry.self, inMemory: true)
}
5. Daily reminders + Privacy Manifest
A nightly nudge is one of the top retention drivers for habit-based apps. Request UNUserNotificationCenter permission once on first launch and schedule a repeating UNCalendarNotificationTrigger. Then add PrivacyInfo.xcprivacy — Apple rejects apps that use APIs like UserNotifications without a manifest, even if you collect no user data.
// Notifications/ReminderManager.swift
import UserNotifications
final class ReminderManager {
static let shared = ReminderManager()
private init() {}
func scheduleDailyReminder(hour: Int = 20, minute: Int = 0) async {
let center = UNUserNotificationCenter.current()
let granted: Bool
do {
granted = try await center.requestAuthorization(
options: [.alert, .badge, .sound]
)
} catch {
return
}
guard granted else { return }
center.removePendingNotificationRequests(
withIdentifiers: ["com.yourapp.daily-mood"]
)
let content = UNMutableNotificationContent()
content.title = "How are you feeling today?"
content.body = "Take 5 seconds to log your mood."
content.sound = .default
var components = DateComponents()
components.hour = hour
components.minute = minute
let trigger = UNCalendarNotificationTrigger(
dateMatching: components, repeats: true
)
let request = UNNotificationRequest(
identifier: "com.yourapp.daily-mood",
content: content,
trigger: trigger
)
try? await center.add(request)
}
}
// Trigger from HistoryView on first appearance:
// .task { await ReminderManager.shared.scheduleDailyReminder() }
// -------------------------------------------------------
// PrivacyInfo.xcprivacy
// File › New › File › Resource › App Privacy
//
// NSPrivacyTracking → false
// NSPrivacyTrackingDomains → (empty array)
// NSPrivacyCollectedDataTypes → (empty array — no data leaves the device)
// NSPrivacyAccessedAPITypes → (empty — no file timestamps,
// user defaults, or disk space APIs)
//
// If you later add a crash reporter or analytics SDK,
// declare its required reason strings here before resubmitting.
// -------------------------------------------------------
Common pitfalls
- Missing
.modelContaineronWindowGroup. SwiftData crashes at runtime — often with a cryptic nil model context message — if the container is not set up before any view executes@Query. Wire it in theAppstruct, not inside a child view. - Chart Y-axis auto-scaling to misleading ranges. Without
.chartYScale(domain: 1...5), Swift Charts fits the axis to the data — a run of "Good" days looks like a flat line at the top, hiding real variance. Always pin the domain explicitly. - Notifications silently not firing on device. The system will not call your
UNUserNotificationCenterDelegatefor alerts while the app is in the foreground unless you implementuserNotificationCenter(_:willPresent:withCompletionHandler:). Add this toAppDelegateor it looks broken during testing. - App Store rejection: missing Privacy Manifest. Since Xcode 15.3, Apple requires
PrivacyInfo.xcprivacyeven for apps that collect zero data. An empty manifest withNSPrivacyTracking = falseis sufficient — but without the file, the binary fails automated checks and never reaches a human reviewer. - SwiftData migration crash after adding a non-optional property. If you ship v1 with
MoodEntry, then add a non-optional stored property in v1.1, existing rows have no value and the store fails to open. Always provide a default or adopt aVersionedSchemamigration plan before adding required fields.
Adding monetization: Subscription
Use StoreKit 2 to implement an auto-renewable subscription — the right model for a habit app where users expect ongoing value. Create a subscription group in App Store Connect under your app's In-App Purchases tab and define a monthly and an annual tier. At runtime, call Product.products(for: ["com.yourapp.premium.monthly", "com.yourapp.premium.annual"]) to fetch live pricing, then check access with Transaction.currentEntitlement(for:) before unlocking premium features such as unlimited history export, home-screen widgets, or custom reminder times. Present a paywall sheet after the user has logged two or three moods — that is the moment they have felt the value, not on first launch. Configure a 7-day free trial on the annual plan directly in App Store Connect (no code required), and test the full purchase and renewal flow using a Sandbox Apple ID on a real device before submission.
Shipping this faster with Soarias
Soarias scaffolds the complete project from your prompt — MoodEntry SwiftData model, Swift Charts trends view, ReminderManager, StoreKit paywall, and a correctly populated PrivacyInfo.xcprivacy — in under two minutes. It also generates the fastlane Fastfile that captures App Store screenshots across all required device sizes on the simulator, writes the ASC metadata, and submits the build to App Store Connect automatically, including attaching your StoreKit subscription to the review version.
For a beginner app like this one, most of the 1–2 weekend estimate goes to learning SwiftData migration rules, debugging simulator notifications, and navigating App Store Connect's metadata requirements. With Soarias handling scaffolding, Privacy Manifest generation, fastlane setup, and ASC submission, most developers reach TestFlight on the same day they start and complete the full App Store submission within a single weekend — without wrestling with provisioning profiles or reading Apple's certificate documentation.
Related guides
FAQ
Does this work on iOS 16?
Not as written. The code requires iOS 17 because it uses @Query with SwiftData, ContentUnavailableView, and the updated Charts axis API. You can adapt it for iOS 16 by replacing ContentUnavailableView with a plain VStack, replacing SwiftData with Core Data, and pinning to the iOS 16 Charts API — but iOS 17 is the practical minimum and now covers the vast majority of active devices.
Do I need a paid Apple Developer account to test?
No — you can run the app on a personal device for free using a personal team in Xcode. However, a paid Apple Developer Program membership ($99/year) is required for TestFlight distribution, App Store submission, and StoreKit sandbox testing with real in-app purchase products.
How do I add this to the App Store?
Archive the app in Xcode via Product › Archive, then use the Organizer to upload the build to App Store Connect. You will need to complete the app metadata (name, subtitle, keywords, description), upload screenshots for every required device size, fill in the privacy questionnaire, and submit for review. First-time submissions typically take 24–48 hours to clear Apple's review queue.
I have no prior SwiftUI experience — is this tutorial realistic for me?
Yes, with realistic expectations. The code above is self-contained and the concepts — @Query, @Environment, SwiftData @Model — are well-documented. Plan for the first weekend to be mostly learning: understanding property wrappers, getting comfortable with the Xcode debugger, and building muscle memory with SwiftUI previews. The "beginner" complexity rating reflects the final code surface area, not the learning curve from zero SwiftUI experience. By weekend two you will be iterating on the actual product.
Last reviewed: 2026-05-12 by the Soarias team.