work · ios

scrollify

swift · swiftui · wkwebview · ~720 loc · github →

scrollify · logging usage and the react focus widget, on device

scrollify is a small swiftui app for scrolling with intention: log your time, see whether the day leaned productive or not, and get nudged hourly to check in. the obvious way to build it is with apple's screen time api, which reads your real app usage. the problem is that api needs a special family-controls entitlement apple does not hand to a free developer account. so the whole app is shaped around a constraint, and that constraint turned out to be the most interesting engineering in it: how do you build a screen-time app without screen-time access?

invert the dependency

the answer is to make manual logging the source of truth and treat the real screen time api as an optional override that may or may not exist. there's no entitlements file in the project at all. the whole pipeline hangs on one abstraction seam in refreshUsage, it tries the real api only if the framework can even be imported, and otherwise falls straight to the manual model.

func refreshUsage() async {
    #if canImport(DeviceActivity)
    if await fetchRealUsage() {      // only if the entitlement exists
        updateProductivityCopy()
        return
    }
    #endif
    updateEntriesFromManual()        // the default path
}
DeviceActivity · optional manual log steppers · the default UsageEntry one shape react widget in a WKWebView Swift → JSON → renderWidget()
the dependency inverted: manual logging is the foundation, the privileged screen-time api is an optional override that only wakes if it's allowed to. both produce the same shape, so everything downstream is identical.

the manual stepper minutes get synthesized into the exact same UsageEntry shape that real data would produce, even down to hardcoding the real bundle ids like com.burbn.instagram, so everything downstream behaves identically whether the numbers came from apple or from your thumb. the app is fully usable with zero entitlements, and the privileged path is dead code that only wakes up if it's allowed to. that's the dependency inverted: the feature you can't guarantee becomes the optional enhancement, not the foundation.

a constraint you can't remove is often just an architecture you haven't discovered yet.

react living inside swiftui

the "focus widget" is a react component, which is a strange thing to find in a native swift app. it isn't react native, it's a real react 18 app loaded from a bundled html file inside a WKWebView, and swift pushes data into it across a one-way bridge: encode the swift model to json, then evaluate a javascript call.

guard didFinishLoading, let jsonData = try? JSONEncoder().encode(parent.data),
      let json = String(data: jsonData, encoding: .utf8) else { return }
webView.evaluateJavaScript("window.renderWidget(\(json));")

there's a load-state guard so data pushed before the page finishes loading isn't dropped, and updateUIView re-pushes on every swiftui state change, so the web widget stays reactive to native @Published updates. the html even detects a failed cdn load and swaps in a plain-dom renderer, so the same swift call works whether or not react actually loaded. two runtimes, one data flow, degrading gracefully at the seam.

derived state, and honest limits

"productive" isn't magic, it's a bundle-id heuristic, anything that looks like youtube, instagram, or tiktok counts against you, with an explicit per-entry override. the headline percentage is reduced live from the usage entries against a twenty-minute scroll target, and hourly nudges are a repeating UNTimeIntervalNotificationTrigger on a 3600-second interval. i'll be honest about what this is: a prototype. there's no persistence yet, state resets on relaunch and re-seeds demo numbers, the "streak" is encouraging copy rather than a computed multi-day count, and the real device-activity branch is essentially untested scaffolding. it installs on a free personal team, which means re-signing every seven days.

the real lesson scrollify taught me is about constraints. i didn't have the entitlement, and instead of waiting on apple, the missing permission forced a cleaner architecture than i'd have written with full access, one where the privileged feature is optional by design and the app stands on its own without it. a river doesn't fight the rock, it goes around and carves the canyon on its way. إن مع العسر يسرا, indeed, with hardship comes ease.

next · incinerator → all work →