← All articles
Jun 12, 20264 min read

Building Habivit: A SwiftUI Habit Tracker with Zero Dependencies

  • iOS
  • SwiftUI
  • SwiftData
  • CloudKit
  • WidgetKit
  • StoreKit 2
  • Habivit
Building Habivit: A SwiftUI Habit Tracker with Zero Dependencies

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 AppIntent in 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.

WRITTEN BY

Shahzaib Muhammad Akram

Senior Frontend EngineerCyberjaya, Malaysia