Inspiration
Every journaling app I tried asked me the same thing: write something. A blank page, a blinking cursor, and the quiet implication that today should have been interesting. I lasted three days. Every time.
So I stopped trying to write days down and started trying to collect them. What if a day were a box instead of a page? You drop things in — a photo from lunch, three things you got done, what you spent, how you felt — and at the end of the month the boxes stack into a picture of how you actually lived, without a single paragraph written.
That is Boxlet.
What it does
Every day is a Board — a small four-column grid you fill with Cards:
- Photo — from the library or straight from the camera, with a full-screen pinch-to-zoom viewer
- Note — a line, not an essay
- Checklist — with recurrence rules, so "gym Mon/Wed/Fri" builds itself
- Mood — one tap
- Expense — in your own currency
- Link — with an auto-fetched preview
- Weather — pulled from your location once, then frozen into that day
- Wake up · Sleep · Timer — three cards for the rhythm of a day
Cards come in three sizes and you drag them around the grid. Layout is stored as order plus size, never as coordinates — so two devices editing the same day cannot punch holes in each other's board.
Then there is the part that makes people stay: a small circle. You can share a day with a handful of friends — friends, not followers. They see your board read-only, react with one of six emoji, comment, and can Save to my Box something you did. There is a Journey hub for looking back, a stats tab, photo and link albums, a monthly history grid, exportable postcards, and a streak leaderboard.
Everything works offline. The network is something that happens later, quietly, in a status bar at the top of the board.
How we built it
One Kotlin codebase, 93% of it shared. Measured on the submitted commit:
| Source set | Kotlin LOC |
|---|---|
commonMain |
33,652 |
androidMain |
943 |
iosMain |
932 |
iosApp/ (Swift) |
357, across 7 files |
androidApp/ (Kotlin) |
42 |
All 341 composables live in commonMain. There is no SwiftUI screen and no Android-only screen. iOS renders the exact same Compose tree through ComposeUIViewController; ContentView.swift is 24 lines that host it and forward the system colour scheme. The 2,274 lines outside commonMain are expect/actual bridges and nothing else — camera, photo picker, secure storage, SQLDelight driver, Ktor engine, location, notification channels and APNs, and four auth providers.
20 Gradle modules, split :core:<noun> and :feature:<noun>. A custom Gradle task, checkModuleGraph, is wired into check and fails the build when a feature module reaches into :core:data, or when one feature depends on another. Because an expect and its actual must live in the same module, every platform seam got its own module: :core:media, :core:database, :core:network, :core:auth, :core:location, :core:notification, :core:billing.
Offline-first sync, written once. SQLDelight is the single source of truth and every read returns a Flow — a repository never touches the network on a read path. A write lands in the entity table and the outbox in one transaction, so a crash mid-write cannot silently lose a change. IDs are client-generated UUIDv7, which is what makes every push idempotent and every offline edit safe. The push endpoint returns four outcomes — applied, stale, invalid, conflict — and each gets its own handler, because treating them all as "retry later" wedges the queue.
Backend: Cloudflare Workers + D1 + R2, twelve migrations deep, with Firebase Auth for identity. user_id always comes from the verified token, never from the request body, and unauthorised reads return 404 rather than 403 so the response never confirms a resource exists.
Monetization: RevenueCat purchases-kmp-core and purchases-kmp-ui. The paywall is declared once in Compose and renders on both stores from one remote configuration — two subscriptions ($2.99/month, $19.99/year), a 3-day free trial on both, one pro entitlement.
Localization: 393 string keys and 22 plurals in four locales — English, Vietnamese, Brazilian Portuguese, European Spanish — plus a shared date, number and currency formatting layer with its own tests.
Testing: 592 tests across 63 files, nearly all in commonTest, so a single run covers both platforms.
Challenges we ran into
Play Console ate our price. Typing 2.99 into the USD price field on a Vietnamese-locale Play Console produces $299.00 — the dot is read as a thousands separator. Worse, editing prices in bulk afterwards silently covers only the rows currently rendered: the footer said "177 countries selected", Bahrain and Poland updated, Thailand and Vietnam did not. The fix was to stop editing and create a fresh base plan instead, which is why one product id carries a :monthly-v2 suffix.
The billing chicken-and-egg. Play will not let you create a subscription until a build containing the Play Billing Library is on a track — but that build is the one you are trying to make. RevenueCat's Test Store breaks the deadlock, except it is hard-blocked in release builds: a signed release with a Test Store key throws up a blocking dialog and the SDK warns it will crash in production.
A ghost bug that only existed in the simulator. Sheets and dialogs rendered completely invisible on iOS — for days we assumed a Compose Multiplatform layering bug. On a real iPhone everything drew correctly. The lesson stuck: any screen with an overlay gets verified on hardware, never on the simulator.
An empty field no checklist mentions. Both subscriptions sat at MISSING_METADATA in App Store Connect with every documented field filled. The cause was the subscription group localization — the group name iOS shows in the system subscription screen — which no submission guide we found lists. Creating en-US and vi localizations flipped both products to READY_TO_SUBMIT.
Accomplishments that we're proud of
- One codebase, both stores, the same version number on the same day, shipped through fastlane on both platforms.
- 93% of the code shared, and not by pushing UI to the platforms — by putting all of the UI in
commonMain. - Four languages at launch, not as a post-1.0 chore.
- An offline-first sync engine with an outbox, tombstones, UUIDv7 idempotency and four distinct conflict outcomes — written once, running identically on both platforms.
What we learned
Compose Multiplatform on iOS is production-ready in a way it simply was not eighteen months ago. The screens were never the hard part.
The expensive part of shipping cross-platform is the seams: auth providers, billing, push notifications, and — by a wide margin — store metadata. Roughly the same amount of calendar time went into App Store Connect and Play Console as into the entire social feature set.
What's next for Boxlet
- Gate the Pro tier now that the purchase pipeline is proven end to end
- Home-screen widgets on both platforms, and an Apple Watch complication for the mood card
- A desktop target — the shared code is already there, only the seams are missing
- Open-sourcing the outbox sync engine and the slot-grid layout as standalone Kotlin Multiplatform libraries
Built With
- cloudflare-d1
- cloudflare-r2
- cloudflare-workers
- coil
- compose-multiplatform
- fastlane
- firebase-authentication
- firebase-cloud-messaging
- gradle
- haze
- koin
- kotlin
- kotlin-multiplatform
- kotlinx-coroutines
- kotlinx-datetime
- kotlinx-serialization
- ktor
- material-design-3
- revenuecat
- sqldelight
- sqlite
- swift
- swiftui
- typescript
- vico

Log in or sign up for Devpost to join the conversation.