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.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- If you read from CNContactStore directly (not just the picker), you must test on a real device — the Contacts framework returns empty results in the simulator by default
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
- Using CNContactStore instead of the picker. Querying
CNContactStoretriggers a system permission alert. UseCNContactPickerViewControllerinstead — it grants scoped, one-time access with no alert and no requiredNSContactsUsageDescription. - Missing cascade delete rule. Removing a
RecipientwithoutdeleteRule: .cascadeleaves orphanedGiftIdearows in the SwiftData store indefinitely. - Floating-point currency display. Store prices as
Doublebut always format with.currencyorspecifier: "%.2f"; raw interpolation prints values like 29.999999999. - App Store rejection: missing Privacy Manifest. Apple rejects builds that call any required-reason API (including
UserDefaultsvia SwiftData's internals) without aPrivacyInfo.xcprivacy. Add the manifest before your first TestFlight upload, not after rejection. - CloudKit schema migration. Enabling
ModelConfiguration(cloudKitDatabase: .automatic)after launch requires a schema migration; adding it retroactively without a versioned schema will crash on upgrade.
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.