Iden Tumuhirwe
All writing

July 7, 2026 · 9 min read

What building SubTrackr taught me about three processes pretending to be one app

Swift · SwiftUI · iOS

I kept losing track of what I was actually paying for. A trial here, a family plan there, a fitness app I forgot to cancel. So I did the thing engineers do when a spreadsheet would solve the problem in ten minutes: I built SubTrackr, a native iOS app to track it properly, with the excuse that I wanted an actual reason to use CloudKit, WidgetKit, and ActivityKit together instead of in isolated tutorials.

The spreadsheet would have worked fine. But the app taught me something the tutorials don't really cover: the home screen widget, the Lock Screen Live Activity, and the app itself are not the same program. They're three separate processes with three separate lifecycles, and getting data between them is the actual engineering problem, not the UI.

The model doesn't live in Core Data

First decision: no Core Data, no SwiftData. Subscription is a plain struct, and CloudKit sync is two hand-written functions on top of it.

extension Subscription {
    init?(from record: CKRecord) {
        guard let name = record["name"] as? String,
              let cost = record["cost"] as? Double,
              let billingCycleRaw = record["billingCycle"] as? String,
              let billingCycle = BillingCycle(rawValue: billingCycleRaw) else {
            return nil
        }
        // ...assign the rest, with a fallback for every optional field
    }

    func toCKRecord() -> CKRecord {
        let record = CKRecord(recordType: "Subscription", recordID: CKRecord.ID(recordName: id))
        record["name"] = name
        record["cost"] = cost
        // ...
        return record
    }
}

Core Data's CloudKit mirroring is genuinely useful once you've fought its migration model at least once. I didn't want to fight it for a struct with a dozen fields. Writing the two conversion functions by hand took about twenty minutes and I've understood every line of my sync code since, which matters more to me for a side project than the schema migrations Core Data would have handled automatically.

The price history is the part I'm least proud of and most okay with. priceHistory: [PriceHistoryEntry] gets encoded to JSON and stuffed into a single CKRecord field rather than modeled as separate linked records. It means I can never query "show me all price increases across every subscription" without pulling every subscription record first. I also never need to, so the honest answer is it was the right shortcut, not just a lazy one.

Never show a blank screen

CloudKitService has a useLocalData flag that flips to true the moment CKContainer.accountStatus comes back as anything other than .available, no iCloud account, restricted, temporarily unavailable, doesn't matter which. At that point the app quietly loads SampleData.subscriptions and gets on with it.

private func checkAccountStatus() {
    container.accountStatus { [weak self] status, error in
        DispatchQueue.main.async {
            switch status {
            case .available:
                break
            default:
                self?.useLocalData = true
                self?.loadSampleData()
            }
        }
    }
}

Fetch failures get a small retry ladder, exponential backoff over three attempts, and only for the CKError codes that are actually transient (network unavailable, service unavailable, rate limited). Anything else fails immediately rather than retrying a request that's never going to succeed. If retries run out, the app falls back to sample data too, and syncState flips to .offline so the UI can say so instead of pretending everything's fine.

None of this is clever. It's the unglamorous branch coverage that makes the difference between "works when I demo it on my phone with good Wi-Fi" and "works when someone opens it in airplane mode by accident." It's easy to only handle the happy path on a side project, and this was the first time I forced myself to sit down and enumerate what CloudKit actually throws at you instead of assuming the network stays cooperative.

The widget can't just ask the app for data

This was the part I underestimated going in. A widget extension is a separate process. It doesn't share memory with the app, it doesn't share a CloudKit container connection, and it can't just call into CloudKitService.shared because there is no shared instance across the process boundary.

The bridge is an App Group, group.com.iden.SubTrackr, and a flattened snapshot written to shared UserDefaults every time subscriptions change:

func saveWidgetData(subscriptions: [Subscription], monthlyTotal: Double, userCurrencyCode: String) {
    let widgetSubscriptions = subscriptions.map { sub in
        WidgetSubscription(
            id: sub.id,
            name: sub.name,
            cost: currencyManager.convertToUserCurrency(sub.cost, from: sub.currency),
            currencyCode: userCurrencyCode,
            billingCycle: sub.billingCycle.rawValue,
            nextBillingDate: sub.nextBillingDate,
            category: sub.category.rawValue,
            iconName: sub.iconName,
            isActive: sub.isActive
        )
    }
    let widgetData = WidgetData(subscriptions: widgetSubscriptions, monthlyTotal: monthlyTotal, userCurrencyCode: userCurrencyCode, lastUpdated: Date())
    guard let encoded = try? JSONEncoder().encode(widgetData) else { return }
    UserDefaults(suiteName: appGroupId)?.set(encoded, forKey: dataKey)
    WidgetCenter.shared.reloadAllTimelines()
}

WidgetSubscription is a deliberately smaller type than Subscription, currency already converted, category already flattened to a string. It lives in a tiny local Swift package called WidgetShared rather than the main app target, because the widget extension and the Live Activity both need the same struct and I didn't want three copies of it drifting apart across targets. That package has zero dependencies and does nothing except define the types both sides agree on.

The widget only ever reads what the main app last chose to write. If the app hasn't run in a while, the widget is showing you old data with total confidence. WidgetCenter.shared.reloadAllTimelines() after every save is what keeps that window small, but it's still a window.

Live Activities have their own idea of state, again

Live Activities add a fourth process to the picture, sort of, since ActivityKit content runs in yet another context and the data it holds has to be Codable because it's what gets serialized to keep the Lock Screen and Dynamic Island updated.

public struct SubscriptionLiveActivityAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable {
        public var nextBillingDate: Date
        public var additionalSubscriptionsCount: Int
        public var relatedSubscriptions: [RelatedSubscription]
    }
    public var subscriptionId: String
    public var subscriptionName: String
    public var subscriptionCost: String
    public var iconName: String
    public var categoryColor: String
}

Notice categoryColor is a String, not Color. I tried passing the category's SwiftUI Color straight through the first time and it didn't survive the trip cleanly, the Live Activity's rendering context doesn't resolve semantic colors the same way the hosting app does. The fix is the kind of thing you only find by hitting it: convert to a hex string before it crosses the boundary, decode it back to a Color on the other side.

extension Color {
    func toHex() -> String? {
        let uic = UIColor(self)
        guard let components = uic.cgColor.components, components.count >= 3 else { return nil }
        let r = Float(components[0]), g = Float(components[1]), b = Float(components[2])
        return String(format: "%02lX%02lX%02lX", lroundf(r * 255), lroundf(g * 255), lroundf(b * 255))
    }
}

Starting the activity pulls related upcoming renewals from the same WidgetDataManager snapshot the widget reads, capped at three, so the Dynamic Island doesn't try to cram your whole subscription list into a shape meant for a countdown and an icon.

The exchange rates are honest about their own age

Currency conversion hits exchangerate-api.com once, caches the result, and treats that cache like it has an expiration date because it does. An hour in, the data is still used but flagged stale-eligible. Two hours in, isStale flips to true and the UI can mention it. A day in, it's not trusted at all.

private func checkIfDataIsStale() {
    guard let lastUpdated = lastUpdated else { isStale = true; return }
    let age = Date().timeIntervalSince(lastUpdated)
    isStale = age > staleDataWarningInterval
    if age > cacheExpiryInterval && !isLoading {
        refreshIfNeeded()
    }
}

A Timer checks this every five minutes. It's a small state machine for something that could have been "fetch once, cache forever, hope for the best," and the only reason it isn't is that a stale exchange rate is a particularly sneaky kind of wrong: the number still looks perfectly plausible, it's just quietly out of date, and nothing about the UI would tell you that on its own.

The part I'll admit isn't what it looks like

Subscriptions can be "shared" with other people, split equally, you pay, they pay. It looks like real multi-user sharing. It isn't. SharedMember is just a name, an email string you type in yourself, and a boolean for who's the payer. There's no CKShare, no invitation, no second person's device ever touches this record. It's a ledger you maintain about people who have no idea the ledger exists.

That's not a bug I'm hiding, it's a scope decision I'm naming honestly: real CloudKit sharing means CKShare records, participant invitations, and handling a second person's app writing to data you don't control. For a subscription tracker, what people actually want is "remind me that Sarah owes me half of Netflix," not a distributed multi-writer system. I built the math, not the plumbing, and the split calculations (yourShareCost, activeSharingBillingMode) are the only part of that feature that's real.

Where it's still rough

The widget-to-app freshness gap is bounded by "whenever the app last wrote," not by any guarantee. If the app crashes mid-edit before the save call, the widget quietly keeps showing the previous state, no error, no indication anything's behind. createSchemaIfNeeded() in CloudKitService saves and then deletes a throwaway test record just to force CloudKit to create the Subscription record type on first launch, which works but reads exactly as much like a hack as it is. And I still haven't tried what happens when a shared subscription's currency and the exchange rate cache are both stale at the same time, which is the kind of compounding edge case that only shows up when a real user hits it.

What actually held up was the decision to treat every one of these boundaries, CloudKit, the App Group, the Live Activity content state, as a place data can silently go stale, and to write down what "stale" means for each one instead of assuming they'd all just stay in sync because they're part of the "same" app. They aren't the same app. They're three processes that agreed, in advance, on a very small shared vocabulary.