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.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Beginner Estimated time: 1–2 weekends Updated: May 12, 2026

Prerequisites

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

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.