Patterns: Experiment. Observe. Analyse.
Inspiration
Two things, really.
1. The n=1 gap. I'd been doing casual self-experiments for years — supplement stacks, sleep tweaks, training changes — and writing them in a notes app. The problem was never collecting the data, it was seeing it. A spreadsheet doesn't tell you "your focus is trending up 3 days into the no-coffee run." I wanted an app whose entire job was turning daily check-ins into a one-glance verdict: is this helping, hurting, or nothing?
2. The on-device AI philosophy. I'd read about running Gemini Nano on-device for a finance app, and the core argument stuck with me: a health/lifestyle app is the worst thing to casually ship to a cloud API. Your sleep, your mood, your supplement list — that's you. Sending it to a server just to get back "this looks like a positive trend" is a bad trade on privacy, latency, cost, and offline use all at once. So I stole the rule wholesale: AI runs on the phone by default, the cloud is only a fallback.
The twist I hadn't seen anyone solve cleanly was doing that across both platforms from one codebase. On Android the on-device model is Gemini Nano via ML Kit. On iOS it's Apple Intelligence via the Foundation Models framework — which is Swift-only, so Kotlin can't touch it directly. That asymmetry is what made the project interesting.
What it does
Patterns (com.tunnxo.patterns) is a local-first, n=1 self-experimentation app built with Kotlin Multiplatform + Compose Multiplatform. One Kotlin codebase ships a real native app to both Android and iOS, and it never talks to a server.
You run tiny experiments on yourself — "no coffee after 2 PM for 14 days", "cold shower every morning", "magnesium before bed" — and log a daily observation rating how you feel across the metrics you care about (energy, focus, sleep quality, mood…). Over time, Patterns surfaces the trend and tells you whether the thing you changed is actually doing anything.
@Serializable
data class Observation(
val date: String, // YYYY-MM-DD
val time: String, // e.g. "8:30 AM"
val ratings: Map<String, Int>, // metricName -> score, -16 to +16
val notes: String? = null,
)
Each metric is scored on a (-16) to (+16) scale, where (0) is neutral. I chose that range so a single unit maps cleanly to (5\%): a full (+16) is "best day ever, (+80\%)", a full (-16) is "(-80\%)". It gives the chart enough granularity to feel honest without forcing people to pick a number from 1–100.
[ \text{trend}(m) = \operatorname{sign}!\left(\overline{r}{\text{last 7}} - \overline{r}{\text{first 7}}\right),\quad r_i \in [-16, +16] ]
Everything — experiments, observations, streaks, badges, settings, the onboarding flag — lives in on-device storage (Room on Android, a shared SQLite-backed store on iOS via multiplatform-settings). There is no account, no analytics, no crash reporting, no backend. I even stripped the INTERNET permission from the Android manifest and added a lint rule that fails the build if any http(s)?:// literal sneaks into commonMain. The only network in the app is RevenueCat's IAP traffic, and that's a deliberate, user-initiated exception.
How we built it
The contract. Everything above the inference layer — ViewModels, screens, the insights engine — only ever sees a tiny interface, written once in commonMain:
expect class OnDeviceSlmService() {
suspend fun isAvailable(): Boolean
suspend fun generateText(prompt: String): String?
}
Each platform fills in the actual. Android wraps an ML Kit GenerativeModel (lazy-init, mutex-guarded, with a "Say hi" smoke test so a model that reports "ready" then falls over becomes an honest isAvailable() == false). iOS flips the direction: I declare a callback interface in Kotlin and let Swift implement it, because suspend fun crosses the boundary as async for free.
class SwiftAppleIntelligenceHandler: NSObject, AppleIntelligenceHandler {
func processPrompt(_ prompt: String) async throws -> String? {
if #available(iOS 26.0, *) {
let session = LanguageModelSession()
let response = try await session.respond(to: prompt)
return response.content
}
return nil
}
}
The handler registers itself once at launch. If Apple Intelligence isn't there, the handler stays nil, isAvailable() returns false, and shared code never even tries. Every feature — the experiment insight, the voice/natural-language observation parser, the AI experiment-planner — is just a prompt sitting on top of one generate() call. Written once, runs on Gemini Nano, on Apple Intelligence, or on the cloud fallback, and I never had to care which.
JSON discipline for small models. Small on-device models love to wrap their JSON in markdown fences and a chatty preamble. Every response goes through a dumb-but-reliable {...} extractor before it ever touches decodeFromString. That single helper saved me more bugs than anything else in the project.
The rest of the app. A Koin module declares the whole graph in shared code — OnDeviceSlmService(), the repos, the coordinator — and each platform resolves OnDeviceSlmService() to its own actual. Onboarding is a four-screen flow with haptics, animated transitions, and a BackHandler. IAP is RevenueCat's purchases-kmp (one KMP dependency, both stores). iOS has a WidgetKit home-screen widget that shows today's pending observation and deep-links patterns://log/<id> straight into the log screen; the Android Glance equivalent is the next sprint. Daily reminders are local-only (UNCalendarNotificationTrigger on iOS, AlarmManager + a re-arming BroadcastReceiver on Android).
Challenges we ran into
- The Swift-only Foundation Models framework. Kotlin/Native can't call it, so instead of fighting it I inverted the dependency: Kotlin defines the handler interface, Swift implements it, and the
suspend→asyncbridge does the rest. No manual continuation juggling. - Never trust the simulator/emulator with on-device AI. Android emulators have no Gemini Nano; the iOS Simulator cheerfully reports
SystemLanguageModel.availability == .availableand then blows up on the firstrespond()with amodelcatalog "no underlying assets"error because it ships no weights. Both platforms now short-circuit fake devices on purpose. - Keeping the prompts honest. On-device models are small, so every prompt is a hard constraint: "Reply ONLY with JSON: {…}". Even then you need the extractor, because they'll add
```jsonfences anyway. - The monolithic repository. The first version stuffed data access, JSON parsing, seed data, and stats into one 400+ line file. Before any feature work I split it into six single-responsibility repos (
ExperimentsRepo,TemplatesRepo,StreakRepo,StatsRepo,BadgesRepo,SettingsRepo) behind one orchestrator, with aResult<T>sealed type so silenttry/catch {}s became surfacedStorageErrorLogentries. It cost half a day and saved every day after it. - Two stores, one shipping deadline. RevenueCat, the App Store products, the Play Console products, the subscription group, the sandbox testing — each store is its own little world, and
purchases-kmponly hides half of it. The other half is App Store Connect and Play Console configuration that no SDK can save you from.
Accomplishments that we're proud of
- One shared module, two on-device brains. Write the experiment logic once, and it runs on a Pixel and an iPhone with neither one knowing which model just answered it. The
expect/actualsplit means the UI is genuinely engine-agnostic. - Local-only is a feature you have to enforce, not just claim. It's easy to say "no backend" and then accidentally pull in an analytics SDK via a transitive dependency. The
INTERNET-permission strip + the URL-literal lint rule keep the promise load-bearing. - A real iOS home-screen widget that deep-links straight into today's log — non-trivial native-module integration in a KMP app, and a big "this is a real app" signal.
- A smoke test that earns its keep. A throwaway
"Say hi"call at init turns a runtime surprise into an honestisAvailable() == false. Small idea, outsized reliability win. - Hardened before shipping. detekt + ktlint + CI gating, a
Result<T>sealed type that killed every silentcatch {}, and a diagnostics ring buffer so failures surface in-app instead of vanishing.
What we learned
expect/actualis the whole game for asymmetric platform features. You write the contract in shared code and hand each platform its own implementation. The UI never learns which engine answered. That separation is worth more than any shared UI component.- Small models lie about being ready. A smoke-test inference at init turns a runtime surprise into a clean
false. Worth the 15 seconds. - Never trust the simulator/emulator with on-device AI. Both Android and iOS fake devices will tell you the model is there and then fail on the first real call. On-device AI is a feature you simply cannot test without real hardware in your hand.
- Local-only is an architectural decision, not a marketing line. It has to be enforced at the build level, or a single dependency drift undoes the whole promise.
What's next for Patterns: Experiment. Observe. Analyse.
- Android Glance widget to match the iOS WidgetKit one — same single-row "today's pending observation" card, same deep-link into the log screen.
BOOT_COMPLETEDre-arm so the Android daily-reminder alarm survives a reboot without needing the app to be opened first.- Trend chart upgrade — a proper line chart on the Insights screen (today only a
RadarChartexists) so the (\text{trend}(m)) verdict is visual, not just a label. - Share-the-streak card — render a pretty "I'm on a 14-day streak" image via Compose
graphics-layerand hand it to the platform share sheet. - Accessibility + dark-mode pass across every screen (Home, Insights, Paywall, Onboarding, empty states), with a 4.5:1 contrast audit and dynamic-type scaling.
- Localization scaffold — English first, structure ready for ES/JA/DE.
- Open-source the shared module ~30 days after launch. The on-device-AI-behind-one-interface pattern is the part most worth giving back.
Log in or sign up for Devpost to join the conversation.