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
Share this project:

Updates

Submission history