How to Build a Screen Time Tracker App in SwiftUI
A Screen Time Tracker lets users visualize their iPhone app usage by category, day, and week — surfacing habits they can actually act on. It's aimed at productivity-focused users who want more insight than Apple's built-in Screen Time provides, without handing data to a cloud service.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight, App Store, and FamilyControls entitlement
- Basic Swift/SwiftUI knowledge
- FamilyControls entitlement — request it via the Apple Developer portal (separate approval, can take 1–3 days)
- A physical iPhone for testing — the Simulator does not support DeviceActivityReport or FamilyControls APIs
- Familiarity with App Extensions; the DeviceActivityReport runs in a separate extension target
Architecture overview
The app is split across three targets: the main SwiftUI app, a DeviceActivityReport extension that reads usage data from the system, and a ManagedSettings extension for optional blocking. SwiftData persists aggregated daily snapshots in the app group container shared between targets. The main app requests FamilyControls authorization on launch, schedules a daily DeviceActivity monitor via DeviceActivityCenter, and renders Swift Charts from the aggregated store. StoreKit 2 drives the subscription paywall before the 7-day trial expires.
ScreenTimeTracker/
├── App/
│ ├── ScreenTimeTrackerApp.swift
│ ├── ContentView.swift
│ └── Models/UsageSnapshot.swift ← SwiftData @Model
├── Features/
│ ├── Dashboard/DashboardView.swift
│ ├── Analytics/AnalyticsViewModel.swift
│ └── Paywall/PaywallView.swift
├── DeviceActivityExtension/ ← separate target
│ └── DeviceActivityReportExtension.swift
└── PrivacyInfo.xcprivacy
Step-by-step
1. Data model
Define a SwiftData @Model for daily usage snapshots stored in the shared App Group container so both the main app and the extension can read and write it.
import SwiftData
import Foundation
@Model
final class UsageSnapshot {
var date: Date
var bundleIdentifier: String
var appName: String
var categoryToken: String // serialized ApplicationToken description
var totalSeconds: Int
var pickups: Int
var notificationCount: Int
init(
date: Date,
bundleIdentifier: String,
appName: String,
categoryToken: String,
totalSeconds: Int,
pickups: Int,
notificationCount: Int
) {
self.date = date
self.bundleIdentifier = bundleIdentifier
self.appName = appName
self.categoryToken = categoryToken
self.totalSeconds = totalSeconds
self.pickups = pickups
self.notificationCount = notificationCount
}
}
// App Group container shared with DeviceActivity extension
let sharedModelContainer: ModelContainer = {
let schema = Schema([UsageSnapshot.self])
let config = ModelConfiguration(
schema: schema,
url: FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.yourteam.screentimetracker")!
.appendingPathComponent("usage.store")
)
return try! ModelContainer(for: schema, configurations: [config])
}()
2. Core UI — usage dashboard
Build the main dashboard with a Swift Charts bar chart breaking down screen time by app, plus a weekly trend line — all driven from the SwiftData query.
import SwiftUI
import Charts
import SwiftData
struct DashboardView: View {
@Query(sort: \UsageSnapshot.date, order: .reverse) private var snapshots: [UsageSnapshot]
@State private var selectedRange: RangeOption = .today
var filtered: [UsageSnapshot] {
let cutoff = Calendar.current.startOfDay(for: selectedRange.cutoffDate)
return snapshots.filter { $0.date >= cutoff }
}
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
Picker("Range", selection: $selectedRange) {
ForEach(RangeOption.allCases) { Text($0.label).tag($0) }
}
.pickerStyle(.segmented)
Chart(filtered) { snap in
BarMark(
x: .value("App", snap.appName),
y: .value("Minutes", snap.totalSeconds / 60)
)
.foregroundStyle(by: .value("App", snap.appName))
}
.frame(height: 260)
.chartXAxis(.hidden)
TotalSummaryRow(snapshots: filtered)
}
.padding()
}
.navigationTitle("Screen Time")
}
}
}
enum RangeOption: String, CaseIterable, Identifiable {
case today, week, month
var id: String { rawValue }
var label: String { rawValue.capitalized }
var cutoffDate: Date {
switch self {
case .today: return Date()
case .week: return Calendar.current.date(byAdding: .day, value: -7, to: Date())!
case .month: return Calendar.current.date(byAdding: .month, value: -1, to: Date())!
}
}
}
3. App usage analytics — FamilyControls + DeviceActivityReport
Request FamilyControls authorization, schedule a daily DeviceActivity monitor, and implement the extension that receives usage callbacks and persists snapshots to the shared SwiftData store.
// In main app — authorization + scheduling
import FamilyControls
import DeviceActivity
class UsageMonitorManager: ObservableObject {
let center = DeviceActivityCenter()
func requestAuthorizationAndSchedule() async {
do {
try await AuthorizationCenter.shared
.requestAuthorization(for: .individual)
scheduleMonitor()
} catch {
print("FamilyControls auth failed: \(error)")
}
}
private func scheduleMonitor() {
let schedule = DeviceActivitySchedule(
intervalStart: DateComponents(hour: 0, minute: 0),
intervalEnd: DateComponents(hour: 23, minute: 59),
repeats: true
)
try? center.startMonitoring(
.daily,
during: schedule
)
}
}
// DeviceActivityExtension target
import DeviceActivity
import SwiftData
class ReportExtension: DeviceActivityReportExtension {
override func intervalDidEnd(for activity: DeviceActivityName) {
// Fetch system-provided usage via DeviceActivityReport API
// Write aggregated UsageSnapshot objects to the shared ModelContainer
let container = sharedModelContainer
let context = ModelContext(container)
// ... map DeviceActivityResults to UsageSnapshot and insert
try? context.save()
}
}
4. Privacy Manifest setup
App Store review requires a PrivacyInfo.xcprivacy that declares FamilyControls usage, any required reason APIs you call (e.g. NSPrivacyAccessedAPICategoryFileTimestamp), and confirms no data leaves the device.
<!-- PrivacyInfo.xcprivacy (Property List source) -->
<?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>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>C617.1</string></array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>CA92.1</string></array>
</dict>
</array>
</dict>
</plist>
Common pitfalls
- Missing entitlement approval: You must request the
com.apple.developer.family-controlsentitlement in App Store Connect before submitting. Submitting without it causes an instant rejection — it is not auto-approved like HealthKit. - Simulator-only testing:
DeviceActivityReportandAuthorizationCenterare no-ops in the Simulator. Build to a real device from day one or you will waste debugging time chasing phantom nil callbacks. - App Group container mismatch: Both the main app and the extension must use the exact same App Group identifier. A typo means the extension writes to a different store and the dashboard shows nothing — no crash, just silence.
- DeviceActivity extension entitlements: The extension target needs its own
.entitlementsfile with FamilyControls listed. Xcode does not inherit the parent app's entitlements automatically. - App Store review — privacy nutrition labels: Even though no data leaves the device, reviewers will query whether you collect usage data. Your
PrivacyInfo.xcprivacyand App Store Connect privacy declarations must both say "no data collected" and align exactly, or your build will be flagged in review.
Adding monetization: Subscription
Use StoreKit 2's Product.products(for:) to load a monthly or annual auto-renewable subscription configured in App Store Connect. Gate the full analytics history (beyond 7 days) and any export features behind an @AppStorage-backed entitlement flag that you set after verifying the Transaction.currentEntitlement(for:) on every app launch. Display a PaywallView using Product.purchase() and listen on Transaction.updates as an async sequence so renewals and family-sharing grants are handled without polling. Offer a 7-day free trial via the subscription's introductory offer — users tracking habits need enough time to see a full week of data before committing.
Shipping this faster with Soarias
Soarias scaffolds the multi-target Xcode project for you — main app, DeviceActivityReport extension, and ManagedSettings extension — with the correct App Group identifiers already wired together. It also generates the PrivacyInfo.xcprivacy from a checklist, pre-fills all required App Store Connect metadata (privacy nutrition labels, export compliance, FamilyControls declaration), sets up fastlane with a Matchfile for all three provisioning profiles, and drives the ASC submission including the FamilyControls entitlement attachment that first-time submitters routinely forget.
For an advanced app like this one — three targets, a sensitive entitlement, and a subscription paywall — the manual setup typically burns 3–5 days of configuration before you write a single line of product code. Soarias compresses that to under two hours, letting you spend the full 2–4 weeks on the analytics and chart work that actually differentiates your app.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. The FamilyControls entitlement, TestFlight distribution, and App Store submission all require an active Apple Developer Program membership ($99/year). You also need to separately request the FamilyControls entitlement from the developer portal — it does not activate automatically when you enroll.
How do I submit this to the App Store?
Archive all three targets together in Xcode (Product → Archive), then upload via Xcode Organizer or xcrun altool. In App Store Connect, attach the FamilyControls entitlement to the version, complete the privacy nutrition labels declaring no data collected, and confirm export compliance. Expect a standard 24–48 hour review cycle, though first submissions with FamilyControls can take longer if the reviewer needs to verify the entitlement approval.
Can I access usage data for other family members' devices?
Only if the user's device is the Family Organizer and you use AuthorizationCenter.shared.requestAuthorization(for: .individual) for the local device, or .family for managed child accounts. You cannot pull another adult family member's usage data — Apple's privacy model explicitly prevents it. Attempting to advertise cross-family monitoring in your App Store listing is a common rejection reason.
Last reviewed: 2026-05-12 by the Soarias team.