How to Build a Gratitude Journal App in SwiftUI
A Gratitude Journal app lets users log three things they're thankful for each day, track their mood over time, and receive a gentle evening push notification to keep the habit alive. It's an ideal first SwiftData project for iOS developers who want something personal and meaningful on the App Store quickly.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store distribution
- Basic Swift/SwiftUI knowledge — familiarity with structs, property wrappers, and views
- A physical iOS 17+ device is strongly recommended for testing
UserNotifications; the simulator delivers local notifications unreliably
Architecture overview
The app's data layer is a single SwiftData @Model class (GratitudeEntry) that stores up to three gratitude strings, a mood integer (1–5), an optional free-form reflection, and a timestamp. ContentView drives a NavigationStack powered by SwiftData's @Query macro, which automatically re-renders the list whenever entries change on disk. A lightweight NotificationManager (marked @Observable) wraps UNUserNotificationCenter for the 8 pm daily reminder, and a SubscriptionStore (also @Observable) handles StoreKit 2 product loading and the purchase flow for the monthly subscription gate.
GratitudeJournal/ ├── GratitudeJournalApp.swift # App entry point + ModelContainer setup ├── Models/ │ └── GratitudeEntry.swift # @Model — date, items[], mood, reflection ├── Views/ │ ├── ContentView.swift # @Query list + NavigationStack │ ├── AddEntryView.swift # Sheet: 3× TextField + mood Picker + TextEditor │ └── EntryDetailView.swift # Read-only detail + inline edit ├── Managers/ │ └── NotificationManager.swift # @Observable UNUserNotificationCenter wrapper ├── Store/ │ └── SubscriptionStore.swift # @Observable StoreKit 2 subscription handler └── PrivacyInfo.xcprivacy # Required for every App Store submission
Step-by-step
1. Project setup
In Xcode 16 choose File → New → Project → iOS App. Set the interface to SwiftUI and storage to SwiftData, then pick a bundle ID you own such as com.yourname.gratitudejournal. Xcode scaffolds a ModelContainer for you — trim it down to the clean version below so there's exactly one container in the app and no sample data.
// GratitudeJournalApp.swift
import SwiftUI
import SwiftData
@main
struct GratitudeJournalApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: GratitudeEntry.self)
}
}
2. Data model with SwiftData
Define GratitudeEntry with the @Model macro. Storing gratitude items as a [String] array keeps the schema flexible — users can have one, two, or three items per day without nullable columns or awkward column naming. Mark the class final; SwiftData requires it.
// Models/GratitudeEntry.swift
import Foundation
import SwiftData
@Model
final class GratitudeEntry {
var id: UUID
var date: Date
var items: [String] // 1–3 gratitude statements
var mood: Int // 1 (😔) to 5 (🥰)
var reflection: String
init(
date: Date = .now,
items: [String] = [],
mood: Int = 3,
reflection: String = ""
) {
self.id = UUID()
self.date = date
self.items = items
self.mood = mood
self.reflection = reflection
}
}
3. Core UI — entry list
Use SwiftData's @Query to fetch entries sorted newest-first. NavigationStack with navigationDestination(for:destination:) handles drill-down; this is the iOS 16+ API and avoids the deprecated NavigationLink(destination:isActive:) overload. Pair the list with ContentUnavailableView for a polished empty state on first launch.
// Views/ContentView.swift
import SwiftUI
import SwiftData
struct ContentView: View {
@Query(sort: \GratitudeEntry.date, order: .reverse)
private var entries: [GratitudeEntry]
@Environment(\.modelContext) private var modelContext
@State private var showingAddEntry = false
var body: some View {
NavigationStack {
Group {
if entries.isEmpty {
ContentUnavailableView(
"No entries yet",
systemImage: "heart.text.square",
description: Text("Tap + to log your first gratitude entry.")
)
} else {
List {
ForEach(entries) { entry in
NavigationLink(value: entry) {
EntryRow(entry: entry)
}
}
.onDelete(perform: deleteEntries)
}
}
}
.navigationTitle("Gratitude Journal")
.navigationDestination(for: GratitudeEntry.self) { entry in
EntryDetailView(entry: entry)
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showingAddEntry = true
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAddEntry) {
AddEntryView()
}
}
}
private func deleteEntries(at offsets: IndexSet) {
for offset in offsets {
modelContext.delete(entries[offset])
}
}
}
struct EntryRow: View {
let entry: GratitudeEntry
private var moodEmoji: String {
let emojis = ["😔", "😐", "🙂", "😊", "🥰"]
return emojis[max(0, min(4, entry.mood - 1))]
}
var body: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(entry.date, style: .date)
.font(.headline)
if let first = entry.items.first {
Text(first)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
Text(moodEmoji)
.font(.title2)
}
.padding(.vertical, 4)
}
}
#Preview {
ContentView()
.modelContainer(for: GratitudeEntry.self, inMemory: true)
}
4. Core feature — daily gratitude entry
AddEntryView is presented as a sheet with three TextFields for gratitude items (second and third are optional), a segmented Picker for mood, and a TextEditor for free-form reflection. The Save button stays disabled until at least the first field contains non-whitespace text.
// Views/AddEntryView.swift
import SwiftUI
import SwiftData
struct AddEntryView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State private var item1 = ""
@State private var item2 = ""
@State private var item3 = ""
@State private var mood = 3
@State private var reflection = ""
private var canSave: Bool {
!item1.trimmingCharacters(in: .whitespaces).isEmpty
}
var body: some View {
NavigationStack {
Form {
Section("Today I'm grateful for…") {
TextField("First thing", text: $item1)
TextField("Second thing (optional)", text: $item2)
TextField("Third thing (optional)", text: $item3)
}
Section("How are you feeling?") {
Picker("Mood", selection: $mood) {
Text("😔").tag(1)
Text("😐").tag(2)
Text("🙂").tag(3)
Text("😊").tag(4)
Text("🥰").tag(5)
}
.pickerStyle(.segmented)
}
Section("Reflection (optional)") {
TextEditor(text: $reflection)
.frame(minHeight: 80)
}
}
.navigationTitle("New Entry")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { saveEntry() }
.disabled(!canSave)
}
}
}
}
private func saveEntry() {
let items = [item1, item2, item3]
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
let entry = GratitudeEntry(items: items, mood: mood, reflection: reflection)
modelContext.insert(entry)
dismiss()
}
}
#Preview {
AddEntryView()
.modelContainer(for: GratitudeEntry.self, inMemory: true)
}
5. Daily reminder notifications
Request notification permission on first launch and schedule a UNCalendarNotificationTrigger that repeats at 8 pm every day. The @Observable NotificationManager is injected via the environment so any view can check authorization status and reschedule the reminder without passing state through the view hierarchy.
// Managers/NotificationManager.swift
import UserNotifications
import SwiftUI
@Observable
final class NotificationManager {
var isAuthorized = false
func requestPermission() async {
let center = UNUserNotificationCenter.current()
do {
isAuthorized = try await center.requestAuthorization(
options: [.alert, .sound, .badge]
)
if isAuthorized { scheduleDailyReminder() }
} catch {
isAuthorized = false
}
}
func scheduleDailyReminder(hour: Int = 20, minute: Int = 0) {
let center = UNUserNotificationCenter.current()
// Remove any existing trigger before rescheduling
center.removePendingNotificationRequests(withIdentifiers: ["daily-gratitude"])
let content = UNMutableNotificationContent()
content.title = "Time to reflect 🙏"
content.body = "What are three things you're grateful for today?"
content.sound = .default
var components = DateComponents()
components.hour = hour
components.minute = minute
let trigger = UNCalendarNotificationTrigger(
dateMatching: components,
repeats: true
)
let request = UNNotificationRequest(
identifier: "daily-gratitude",
content: content,
trigger: trigger
)
center.add(request)
}
func cancelReminder() {
UNUserNotificationCenter.current()
.removePendingNotificationRequests(withIdentifiers: ["daily-gratitude"])
}
func checkAuthorizationStatus() async {
let settings = await UNUserNotificationCenter.current().notificationSettings()
isAuthorized = settings.authorizationStatus == .authorized
}
}
// Usage in GratitudeJournalApp.swift:
// @State private var notifications = NotificationManager()
// ContentView().environment(notifications)
// Then in a settings view: await notifications.requestPermission()
6. Privacy Manifest
Apple requires a PrivacyInfo.xcprivacy file in every App Store submission as of 2024. Add it via File → New → File from Template → App Privacy in Xcode. If you use UserDefaults to persist the notification hour preference, declare it with reason code CA92.1 — "Read/write data from/to a file that is accessible to the app." This app collects no user data and uses no tracking domains.
<!-- PrivacyInfo.xcprivacy — add to your app target, not an 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
- Duplicate entries for the same day. SwiftData won't enforce uniqueness by date for you. Add a guard in
saveEntry()that checks if an entry already exists usingCalendar.current.isDateInToday(entry.date)before inserting, or let users have multiple entries and sort by time instead. - Foreground notification suppression. By default iOS silently discards local notifications when your app is in the foreground. Implement
UNUserNotificationCenterDelegate.userNotificationCenter(_:willPresent:withCompletionHandler:)in yourAppDelegateand call the handler with[.banner, .sound]so the 8 pm reminder appears even if the app is open. - SwiftData migration crash on update. Adding a non-optional property to
GratitudeEntryafter your first ship without aVersionedSchemaandSchemaMigrationPlanwill crash on existing installs. Either make new properties optional or set up a lightweight migration before pushing the update to the App Store. - App Store rejection: subscription without restore button. Guideline 3.1.1 requires that any app with a subscription include a clearly visible "Restore Purchases" control that users can reach without hitting a paywall. Place a restore button in your settings screen and call
AppStore.sync()from it. - Scheduling notifications without checking permission first.
UNUserNotificationCenter.add(_:)silently fails if the user hasn't granted permission or later denied it in Settings. Always callgetNotificationSettingsbefore scheduling and surface a deep link toUIApplication.openSettingsURLStringwhen authorization is.denied.
Adding monetization: Subscription
Use StoreKit 2's async/await API to offer an auto-renewable monthly subscription that unlocks premium features — mood trend charts, streak tracking, or iCloud sync are all natural upgrade hooks for a gratitude journal. Create your subscription product in App Store Connect under Monetization → In-App Purchases → Create, assign it an ID like com.yourname.gratitudejournal.premium.monthly, then load it at launch with Product.products(for:). After a successful purchase, verify with Transaction.currentEntitlements rather than trusting the purchase result alone — this also handles users who reinstall or switch devices and satisfies App Store review guideline 3.1.1. Wrap the StoreKit state in an @Observable SubscriptionStore class so every view that reads store.isPremium re-renders automatically when the entitlement changes, without needing NotificationCenter observers or manual UI refreshes.
Shipping this faster with Soarias
Soarias automates the parts of this project that have nothing to do with your actual app idea. After you describe your app, it scaffolds the full Xcode project with GratitudeEntry already wired into a ModelContainer, generates a correct PrivacyInfo.xcprivacy with the UserDefaults reason code pre-filled, configures fastlane match for code signing against your Apple Developer account, and runs fastlane deliver to push your metadata, App Store screenshots, and binary to App Store Connect — no manual Organizer clicks, no certificate export, no screenshot resizing.
For a beginner-complexity app like this one, Soarias typically shrinks the gap between working code and a live TestFlight link from an afternoon of certificate wrangling and metadata entry to around 15–20 minutes of answering prompts. The one-time $79 price pays for itself the first time you avoid a binary rejection for a missing Privacy Manifest or a submission bounce due to a misconfigured entitlement.
Related guides
FAQ
Does this work on iOS 16?
No. SwiftData and the @Query macro require iOS 17 or later. If you need iOS 16 support, replace SwiftData with Core Data, swap @Query for @FetchRequest, and set up an NSPersistentContainer. The UserNotifications and SwiftUI view code is fully compatible with iOS 16 and can stay unchanged.
Do I need a paid Apple Developer account to test?
You can run the app on a physical device and in the simulator with a free Apple ID using Xcode's personal team. However, a paid Apple Developer Program membership ($99/year) is required to distribute via TestFlight or the App Store, and to test StoreKit 2 subscriptions with real sandbox accounts. Local notifications work on device with a free account.
How do I add this to the App Store?
Archive your build in Xcode via Product → Archive, then distribute through Xcode Organizer or upload with fastlane deliver. In App Store Connect, fill in your app's name, subtitle, description, keywords, and upload at least one screenshot per device class. Create your subscription product under Monetization, set pricing, and submit the app version for review. First submissions typically take 24–48 hours to review.
Can I use Core Data instead of SwiftData as a beginner?
Yes, and Core Data is a reasonable choice if you want broader iOS version support or are already familiar with it. Replace @Model with an NSManagedObject subclass generated from a .xcdatamodeld file, swap @Query for @FetchRequest, and initialize an NSPersistentContainer in a PersistenceController singleton. That said, SwiftData is significantly less boilerplate for a single-entity app like this, and it's the direction Apple is investing in for iOS 17+.
Last reviewed: 2026-05-12 by the Soarias team.