Inspiration

Advice about eating better arrives in two shapes: track everything, or cook something else. Neither helps at nine in the evening when you are already holding a packet of instant noodles. The question people actually have in that moment is smaller and more answerable — what one thing would make this hold me until morning?

So the app answers only that. It never asks you to remove, swap or reduce anything. There is no domain model for subtraction, and there never will be.

What it does

Type what you are about to eat. You get a plain-English read on what the meal is missing and up to three things to add, sorted by effort, chosen for where you are and what you keep in your kitchen.

  • Instant noodles → crack an egg in, throw in frozen peas.
  • Toast → peanut butter, a boiled egg on the side.
  • The same meal in an airport gives different answers, because nothing there asks you to cook.

Two of three components is the target, and a meal that already reaches two is told so rather than pushed toward a third. Tap "not for me" and that ingredient never appears again. Tap Add and the combination is saved, ready to repeat.

There are no calories, no macros, no weights, no daily totals, no streak to break, and nothing to review later. Say a meal already covers you and the app agrees and stops talking.

How we built it

One Compose Multiplatform codebase for iOS and Android. 91% of application code is shared by line — 9,354 lines in commonMain against 440 Android, 298 iOS Kotlin and 227 Swift — and all 36 UI and ViewModel files are shared. Platform code is limited to app entry, haptics, notification permission, URL opening, the push bridge, the HTTP engine and the launch screen.

Two architectural rules did most of the work:

  • Dependency injection instead of expect/actual. Every platform capability is an interface injected with Koin, so each one can be faked in commonTest with no mocking framework.
  • Nothing throws across the Kotlin/Swift boundary. An uncaught exception reaching Swift kills an iOS app instantly, with no usable trace, so every fallible operation returns a sealed result that maps to designed copy.

The suggestions come from an authored catalogue bundled in the app: 664 meals, 123 additions, 84 ingredients. Allergen data is written by hand, never generated, and excluded from the candidate set before anything ranks it, so a flagged allergen is structurally unreachable rather than filtered afterwards. Missing data counts as unknown and fails closed. An LLM tier and a Cloudflare Worker exist in the repository but are switched off in the shipped builds: everything you see answers on-device, in under a frame, with no network at all.

Purchases run through RevenueCat's Kotlin Multiplatform SDK on both stores. Push runs through a wrapper we wrote and published as its own Apache-2.0 library.

241 unit tests, plus 28 design guards that fail the build — not lint rules, but the product's own constraints written down as code: no digits in a suggestion, no banned vocabulary, no shadows, no advertising ID, no ViewModel coroutine without an exception handler.

Challenges we ran into

The paywall opened underneath the iOS system alert. We asked for notification permission and raised the upgrade sheet in the same breath. On iOS the permission prompt is asynchronous, so the sheet arrived first and sat behind it. Found while recording the demo video, fixed by making the permission call report its answer so the sheet waits.

A push told people to try something they had just done. Tags were written once per launch, so the "has completed a check" flag stayed false until the app was next opened — and the permission prompt appears seconds after that first check. Our activation journey, which enters two hours after a first session, therefore nudged people who had already used the app. The dashboard said it plainly: 36 delivered, zero clicks, five unsubscribes. We stopped the journey, wrote the tag at the moment the check completes, and added a guard so it cannot regress.

Google Play promo codes do not grant a subscription. They attach the offer to the account and apply it when the person subscribes, which is the opposite of Apple's offer codes. We only learned this by redeeming our own codes and hitting the paywall. Our instructions for reviewers said the wrong thing and would have made working codes look broken.

Accomplishments that we're proud of

Live on both stores, through three review cycles, with two real defects caught and shipped rather than discovered by users. 91% shared code including every screen. A push wrapper extracted and published for anyone else building on Kotlin Multiplatform. And a set of guards that make the product's promises — it only ever adds, it never shows a number that reads as a budget — into things the build enforces rather than things we remember.

What we learned

Rules survive only if a script enforces them. Every guard in this repository exists because a written rule was broken anyway.

Store mechanics differ in ways no test suite can catch. The only way to find out what a reviewer will experience is to redeem your own codes on your own phone.

Dashboard numbers are a bug report. A zero click-through with unsubscribes is not weak copy, it is the wrong message reaching the wrong people.

What's next

Direct interop for the push library on iOS so it needs no Swift shim. More authored additions, which is the only thing that widens what the app can answer. And an honest decision about the subscription before the season ends: the tiering means the app keeps working with the backend switched off entirely, and we intend to preserve that.

Built With

  • android
  • compose-multiplatform
  • gradle
  • ios
  • kermit
  • koin
  • kotlin
  • kotlin-multiplatform
  • kotlinx-datetime
  • kotlinx-serialization
  • ktor
  • multiplatform-settings
  • onesignal
  • revenuecat
  • sqldelight
  • swift
  • xcode
Share this project:

Updates

Submission history