```html How to Build a Gift Tracker App in SwiftUI (2026)

How to Build a Gift Tracker App in SwiftUI

A Gift Tracker app helps users organise gift ideas for each person in their life, attach price estimates, and stay inside a per-person budget. It targets anyone who dreads last-minute shopping or routinely over-spends on presents.

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

Prerequisites

Architecture overview

The app uses two SwiftData @Model classes — Recipient and GiftIdea — stored in a local ModelContainer. The Contacts framework (via CNContactPickerViewController) optionally links a Recipient to an address-book entry without requiring a permission prompt. All state flows through SwiftUI's @Query and @Environment(\.modelContext); there is no separate view-model layer.

GiftTracker/
├── GiftTrackerApp.swift       # ModelContainer setup
├── Models/
│   ├── Recipient.swift
│   └── GiftIdea.swift
├── Views/
│   ├── RecipientListView.swift
│   ├── GiftDetailView.swift
│   └── AddRecipientView.swift
└── PrivacyInfo.xcprivacy

Step-by-step

1. Data model

Define the two @Model classes and connect them with a cascade-delete relationship so removing a recipient also removes all their gift ideas.

import SwiftData

@Model
final class Recipient {
    var name: String
    var budget: Double
    var occasion: String
    var giftDate: Date
    var contactIdentifier: String?
    @Relationship(deleteRule: .cascade) var gifts: [GiftIdea] = []

    init(name: String, budget: Double = 50.0, occasion: String = "Birthday") {
        self.name = name; self.budget = budget
        self.occasion = occasion; self.giftDate = .now
    }
}

@Model
final class GiftIdea {
    var title: String
    var price: Double
    var isPurchased: Bool
    var notes: String

    init(title: String, price: Double = 0, notes: String = "") {
        self.title = title; self.price = price
        self.isPurchased = false; self.notes = notes
    }
}

2. Core UI — recipient list

Show every recipient in a List with a ProgressView bar that fills as purchased gifts eat into the budget.

struct RecipientListView: View {
    @Query(sort: \Recipient.giftDate) private var recipients: [Recipient]
    @Environment(\.modelContext) private var context
    @State private var showAdd = false

    var body: some View {
        NavigationStack {
            List(recipients) { recipient in
                NavigationLink(destination: GiftDetailView(recipient: recipient)) {
                    VStack(alignment: .leading, spacing: 4) {
                        Text(recipient.name).font(.headline)
                        let spent = recipient.gifts
                            .filter(\.isPurchased).reduce(0) { $0 + $1.price }
                        ProgressView(value: min(spent / max(recipient.budget, 0.01), 1))
                            .tint(spent > recipient.budget ? .red : .accentColor)
                        Text("$\(spent, specifier: "%.2f") of $\(recipient.budget, specifier: "%.2f")")
                            .font(.caption).foregroundStyle(.secondary)
                    }.padding(.vertical, 4)
                }
            }
            .navigationTitle("Gift Tracker")
            .toolbar { Button("Add", systemImage: "plus") { showAdd = true } }
            .sheet(isPresented: $showAdd) { AddRecipientView() }
        }
    }
}

3. Gift ideas and budget detail

The detail view is the core feature: users add priced gift ideas, toggle them purchased, and watch the remaining budget update in real time.

struct GiftDetailView: View {
    @Bindable var recipient: Recipient
    @Environment(\.modelContext) private var context
    @State private var showAddGift = false

    private var totalSpent: Double {
        recipient.gifts.filter(\.isPurchased).reduce(0) { $0 + $1.price }
    }

    var body: some View {
        List {
            Section("Budget") {
                LabeledContent("Budget", value: recipient.budget,
                               format: .currency(code: "USD"))
                LabeledContent("Remaining") {
                    Text(recipient.budget - totalSpent, format: .currency(code: "USD"))
                        .foregroundStyle(totalSpent > recipient.budget ? .red : .green)
                }
            }
            Section("Gift Ideas") {
                ForEach(recipient.gifts) { gift in
                    Toggle(gift.title, isOn: Bindable(gift).isPurchased)
                }
                .onDelete { idx in idx.forEach { context.delete(recipient.gifts[$0]) } }
                Button("Add Idea") { showAddGift = true }
            }
        }
        .navigationTitle(recipient.name)
        .sheet(isPresented: $showAddGift) { AddGiftView(recipient: recipient) }
    }
}

4. Privacy Manifest setup

Add a PrivacyInfo.xcprivacy file to your Xcode target (File → New → Privacy Manifest) and declare any required API access — Contacts picker access must be listed.

<?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: One-time purchase

Implement the one-time purchase with StoreKit 2 using a Product.products(for:) call at app launch to fetch your non-consumable IAP from App Store Connect, then gate premium features (unlimited recipients, export to CSV, contact syncing) behind a Transaction.currentEntitlement(for:) check. Store the entitlement result in a simple @AppStorage("isPro") flag so every view can read it without re-querying StoreKit on each launch. Keep the free tier genuinely useful — three recipients with full budget tracking — so users see value before hitting the paywall.

Shipping this faster with Soarias

Soarias scaffolds the full SwiftData model layer, wires up the ModelContainer in your App entry point, generates the PrivacyInfo.xcprivacy with the correct required-reason keys, sets up fastlane with deliver and snapshot, and submits the binary to App Store Connect — all without you touching Xcode's organiser or the ASC web UI.

For a beginner-complexity app like this one, a typical solo developer spends 3–5 hours on project setup, provisioning profiles, Privacy Manifest, and ASC metadata. Soarias collapses that to under 20 minutes, so the 1–2 weekend estimate applies almost entirely to writing feature code rather than fighting Xcode configuration.

Related guides

FAQ

Do I need a paid Apple Developer account?

Yes, to distribute via TestFlight or the App Store you need the $99/year Apple Developer Program membership. You can build and run on your own device for free with a personal team, but you cannot invite testers or publish without a paid account.

How do I submit this to the App Store?

Archive the app in Xcode (Product → Archive), upload via the Organiser, then complete the App Store Connect listing — screenshots, description, privacy nutrition labels, and pricing. Apple's review typically takes 24–48 hours for a straightforward utility app with no server-side component.

Can users back up their gift data to iCloud?

Yes. Pass cloudKitDatabase: .automatic to ModelConfiguration and enable the CloudKit capability in your target's signing & capabilities tab. SwiftData handles sync automatically, but plan your schema carefully before launch — adding required attributes later requires a versioned migration or your app will crash on upgrade for existing users.

Last reviewed: 2026-05-12 by the Soarias team.

```