Building Habivit: A SwiftUI Habit Tracker with Zero Dependencies
- iOS
- SwiftUI
- SwiftData
- CloudKit
- WidgetKit
- StoreKit 2
- Habivit
Habivit is my habit tracker on the App Store — built around habit stacking (James Clear) and implementation intentions (Peter Gollwitzer): instead of a flat to-do list, you chain habits to existing triggers — "After waking up → Run → Read" — and track them as ordered stacks.
The constraint I set for myself: no external dependencies. No CocoaPods, no SPM packages. SwiftUI, SwiftData, CloudKit, WidgetKit, StoreKit 2, UserNotifications — all first-party. This post covers the four problems that turned out to be genuinely hard.
1. SwiftData + CloudKit: design your models for the strictest backend
The data layer is three @Model entities — Habit, HabitEntry, HabitStack — synced across devices via CloudKit. CloudKit imposes rules that plain SwiftData doesn't: every stored property needs a default value, and every relationship must be optional. So the schema looks slightly unnatural on purpose:
@Model
final class Habit {
var name: String = ""
@Relationship(deleteRule: .cascade) var entries: [HabitEntry]? // optional for CloudKit
// ...
}
The second trap is initialization. A CloudKit-backed ModelContainer crashes at launch if there's no iCloud account — simulators, logged-out users, unsigned builds. Habivit initializes through a three-tier fallback: try the CloudKit configuration, catch and fall back to a local on-disk store, and if even that fails, an in-memory container. The app degrades from "synced" to "local" to "ephemeral" but never crashes on launch.
2. App ↔ widget sync without sharing a database
Interactive widgets run in a separate process. Pointing both processes at the same SwiftData store invites locking and contention, so Habivit never shares the ModelContainer. Instead, a SharedHabitStore maps the models to lightweight Codable DTOs and exchanges them through App Group UserDefaults.
- App → widget: on every data change, the app pre-computes everything the widget will render — 7- and 28-day completion grids, streaks, momentum — pushes the DTOs to the App Group, and calls
WidgetCenter.shared.reloadAllTimelines(). The widget never computes; it only draws. - Widget → app: tapping "complete" in a widget fires an
AppIntentin a background process that can't safely write to the database. The intent appends"<UUID>|<startOfDay timestamp>"to a pending queue in the App Group. When the app next becomes active, it drains the queue and converts each entry into a real SwiftData commit.
It's a classic outbox pattern, applied to two processes on one phone. Both directions are eventually consistent, and neither process ever blocks on the other.
One related fix: widget configuration originally bound to AppEntity identifiers, which require a resolver round-trip that intermittently failed after device restarts — widgets would reset to empty. The fix was a custom DynamicOptionsProvider that serves plain stack-name strings from the shared store. WidgetKit stores the string directly; no resolution, no dropouts.
3. Momentum instead of streaks
Streaks are motivating until you miss one day and the counter resets to zero — the most demotivating possible outcome for a habit app. Habivit replaces the headline metric with momentum: a recency-weighted score over a sliding 21-day window where each day's contribution decays by 0.88^daysAgo. Completions are summed and normalized against the maximum possible score, yielding a 0–1 value bucketed into five states: dormant, building, flowing, strong, on fire.
Miss a day and your momentum dips slightly instead of cratering. Yesterday matters more than three weeks ago, which matches how habits actually feel. The full history still renders as a GitHub-style contribution grid, so no information is lost — just the framing.
4. StoreKit 2 lifetime unlock
Monetization is a single one-time purchase — no subscription. The free tier allows one active habit; Pro unlocks unlimited habits, stacks, momentum, the contribution grid, iCloud sync, and widgets.
StoreKit 2 makes the happy path short, but two details matter for a lifetime product: verify entitlements at launch via Transaction.currentEntitlements, and keep a detached background task listening on Transaction.updates for the whole app lifetime. The listener is what makes Ask to Buy approvals and purchases made on another device unlock without a restart — miss it and those users think the purchase failed.
Takeaways
- A zero-dependency iOS app is entirely practical in 2026 — the Apple stack covers persistence, sync, widgets, and payments. What you give up in convenience you get back in not chasing breaking releases.
- If CloudKit might ever back your SwiftData store, write the models CloudKit-style from day one. Retrofitting optionality into a shipped schema is a migration.
- Treat the app/widget boundary like a distributed system, because it is one: DTOs, an outbox queue, and one writer.
Habivit is live on the App Store, published under NorthLab Apps.