Porting Nomi from SwiftUI to Jetpack Compose in under two hours
An AI coding agent turned a 10,500-line SwiftUI app into a fully native Android app — same backend, same UX, Material Design throughout. Here is what the port decided, what it deliberately did not port, and the two bugs a real user found.
Nomi is a family dinner-planning app: an AI assistant that plans meals, builds shopping lists that sync live between household members, walks you through cooking step by step, and chats about dinner. The iOS app is pure SwiftUI targeting iOS 26 — 88 Swift files, about 10,500 lines — talking to a Go backend that owns nearly all the business logic. The whole Android project started with one prompt.
I need you to make a plan for creating an android app based on the ios app. Uses same api, same great ux, but standard material design with the branding copied over.
The constraint set was interesting: not a pixel clone of iOS, but a proper Material Design app that feels like Nomi — the warm cream-and-yellow palette, the Fraunces serif, the "Nomi voice" (italic serif in dark yellow), carried onto standard Android components. And later, an explicit sharpening of that constraint: the Android app should be idiomatic Android, Kotlin and Compose. The iOS app is a behavioral reference, never a structural one.
Recon before writing a line
The first move was not writing Kotlin. It was three parallel deep-reads of the iOS codebase, each with a different lens: screens and flows (every view file, the navigation structure, which surfaces are full-screen covers vs. sheets, every ViewModel and what state it owns), the API surface (every endpoint with its request and response shapes, the auth flow, the streaming mechanics, error-handling conventions), and branding (every color token extracted from the asset catalogs with light and dark hex values, the full type scale, corner-radius conventions measured across the codebase).
This produced a specification the plan could actually be written against, and it surfaced things no plan should be written without. The repo's own CLAUDE.md was stale: it described a MealStore class and a three-tab layout deleted months earlier. The real central store was AppViewModel, chat had been rewritten to stream over SSE, and there were four tabs, one of which was fake. Read the source, not the docs — and then correct the docs, so the next session starts from truth.
The API is camelCase except where it isn't: the ORM's own key casing leaks through in places, a few payloads are snake_case, one type decodes two different wire shapes, and chat history payloads carry no type tag at all — the client infers the component type by probing which fields exist, in a specific order. Chat and the shopping list are both Server-Sent Events consumers with different lifecycles. Sign-in is standard OAuth 2.0 with PKCE against the app's own backend. And several files were dead code — a commented-out similarity search, an unreferenced account-setup screen, unwired cook-mode components. Feature parity explicitly meant not porting these.
Recon also covered the machine: which SDK and AVDs existed, that Gradle 8.13's wrapper was cached, that the system JDK 26 was too new for Gradle, and that an existing Compose project on the machine established the conventions to match. Mid-planning, a git pull landed a batch of brand-new iOS features — recipe categories, a pinch-zoom image viewer, an AI badge on hero images, custom household compositions. The plan was re-derived against the new HEAD before any code was written, which is exactly the failure mode plans usually have: they go stale the moment the reference moves.
The stack: boring on purpose
The backend owns suggestions, substitutions, aisle assignment, the onboarding script and the chat tool loop. The client is a thin, stateful renderer. That shaped every stack decision toward less machinery. Kotlin, Compose and Material 3 were the only non-negotiable requirement. A single app module with no Hilt, because six ViewModels do not need a DI framework — manual constructor injection through an AppContainer on the Application is plenty. OkHttp plus kotlinx.serialization instead of Retrofit, because the API's quirks (SSE, dual-shape decoding, ad-hoc bodies) fight Retrofit's rigidity. Coil 3 for images, with a disk cache and a bounded retry for lazily-generated recipe images.
No Room and no offline cache, because the iOS app persists nothing but the auth token and parity kept the port honest. NavigationSuiteScaffold for a bottom bar on phones and a rail on tablets, for free. Token storage is isolated behind a small TokenStore that matches the iOS app's persistence model, so the backing store can be upgraded without touching a single caller.
class AppContainer(val appContext: Context) {
val tokenStore = TokenStore(appContext)
val okHttp = OkHttpClient()
val api = ApiClient(
baseUrl = BuildConfig.BASE_URL,
client = okHttp,
tokenProvider = { tokenStore.token },
)
val authManager = AuthManager(api, tokenStore)
}Gating is state, not navigation
Before you reach the app proper, three walls can stand in the way: not signed in, waitlisted, onboarding incomplete. iOS models these as non-dismissable full-screen covers. The Android version models them as what they actually are — states, not destinations.
when {
!isAuthenticated -> AuthScreen(...)
status?.waitlisted == true -> WaitlistScreen(...)
status?.onboardingComplete == false -> OnboardingScreen(...)
else -> NomiNavHost(...)
}There is no route for auth that a back press could pop; BackHandler swallows back on the gates. The ordering encodes a product rule worth preserving: waitlist takes precedence over onboarding, so a waitlisted account can never be dumped into the questionnaire. A related subtlety carried over from the Swift source: only the account-status call is allowed to turn an auth failure into a sign-out. Every other endpoint's failure keeps the last known status, so one flaky poll can't collapse a gate screen into an empty app.
Tabs are state, covers are routes
The three tabs (Tonight, Plan, Shopping list) are a rememberSaveable enum switched inside the scaffold, not navigation destinations. This keeps the shared AppViewModel trivially scoped and means tab switching costs nothing. Everything that was a full-screen cover on iOS — chat, recipe detail, cook mode, profile, library, import, the catalogs — became a NavHost route, because a full-screen surface you can back out of is exactly what a Compose destination is.
The fourth tab is fake, and the port made it less hacky than the original. iOS uses a role: .search tab that renders Color.clear, presents a cover, and bounces the selection back after 100 ms. Android's bar item simply never selects.
item(
selected = false, // never selects; it opens the chat route
onClick = { navController.navigate("chat?context=${tab.chatContext}") },
icon = { Icon(Icons.Outlined.ChatBubbleOutline, null) },
label = { Text(stringResource(R.string.tab_chat)) },
)The context argument matters: chat opens with a different greeting and different quick actions depending on whether you came from Tonight, Plan, Shopping, cook mode or a recipe. That per-context opening is client-side product behavior lifted directly from the iOS source.
One shared ViewModel, and mutations that can't die
AppViewModel is the single source of truth for everything the three tabs show: the plan, suggestions, favourites, the insight card, and the entire shopping list state. It is activity-scoped, injected into every tab, and holds Compose mutableStateOf snapshots so the UI recomposes from plain property reads. Two rules crystallized during the project, one by design and one by bug report.
The first: the ViewModel keeps its own dependent state coherent. Removing a planned meal doesn't just update the plan — the same job refreshes the shopping list, because the shopping list derives from the plan. No screen is responsible for knowing what else a mutation touches. The second: no UI-scoped coroutine ever carries a server mutation. Every mutating action is a fire-and-forget ViewModel function running in viewModelScope.
fun removePlanned(item: Planned) {
planned = planned.filter { it.id != item.id } // optimistic
shoppingPlanned = shoppingPlanned.filter { it.id != item.id }
viewModelScope.launch {
runCatching { api.plannedAction(item.id, "remove") } // survives UI churn
load()
loadShoppingList()
}
}One more scoping decision: cook mode does not get its own ViewModel or route. It shares RecipeDetailViewModel with the detail screen and renders in place of it, because the two surfaces genuinely share state — the recipe, the current step, and the running timers (a 1 Hz ticker in viewModelScope driving countdowns, with FLAG_KEEP_SCREEN_ON while cooking). Splitting them would have meant synchronizing two owners of the same timers for no benefit.
The client is lenient because the server is alive
The whole data layer is built on one shared Json configuration that mirrors the philosophy of the hand-written Swift decoders. A live product's server will always be slightly ahead of the client, and a new enum value must never crash the home screen.
val NomiJson = Json {
ignoreUnknownKeys = true // new fields are fine
coerceInputValues = true // unknown enum values degrade to null
explicitNulls = false // request bodies omit nulls, like JSONEncoder
encodeDefaults = true
}On top of that sit the two genuinely gnarly decoders. Planned arrives in two shapes — a nested object for planned recipes, or a flat one for planned-menu courses — resolved by a short serializer that probes for the nested key and falls back to the flat form.
object PlannedSerializer : KSerializer{
override fun deserialize(decoder: Decoder): Planned {
val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject
return if (obj.containsKey("recipe")) {
decodeNested(decoder, obj)
} else {
decodeFlat(obj)
}
}
} Chat history payloads are duck-typed the same way. The stored JSON has no type discriminator, so the client identifies each component by checking for a series of marker fields in a fixed order. That order is a wire contract shared with the web client; get it wrong and a confirm card renders as a recipe card. The Kotlin serializer reproduces it check-for-check.
Scattered around the client are the small contracts that only reading the source reveals. The onboarding answer endpoint treats one non-success status as success, because it means the client and server disagree about the current step and the body carries the step to show. And recipe images are generated lazily, so the image composable retries on a short interval, bounded at a fixed number of attempts unlike iOS's forever-loop, with the memory-cache key varied per attempt so Coil doesn't serve the cached failure.
Branding without copying UIKit
The Nomi look is a small system: warm cream background, brand yellow, teal speech surfaces, and one signature move — the primary button inverts between modes, a near-black pill with cream text in light mode, a yellow pill with dark text in dark mode. All 23 color tokens were extracted from the iOS asset catalog JSONs with both variants, so the dark theme is the designed dark theme, not an auto-darkened one.
The token set lives in a CompositionLocal — the source of truth for Nomi-branded components — while a Material ColorScheme is populated alongside it so stock components stay on-brand with zero per-usage styling.
@Composable
fun NomiTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val colors = if (darkTheme) DarkNomiColors else LightNomiColors
CompositionLocalProvider(LocalNomiColors provides colors) {
MaterialTheme(
colorScheme = materialScheme(colors, darkTheme),
typography = NomiTypography,
shapes = NomiMaterialShapes,
content = content,
)
}
}Fraunces is a variable font, so one TTF serves every weight through FontVariation, and the iOS type scale came over as a NomiFonts object of TextStyles.
private fun fraunces(weight: Int, italic: Boolean = false) = Font(
resId = if (italic) R.font.fraunces_italic else R.font.fraunces,
weight = FontWeight(weight),
style = if (italic) FontStyle.Italic else FontStyle.Normal,
variationSettings = FontVariation.Settings(FontVariation.weight(weight)),
)Localization is where the Android way beat the iOS way outright. iOS uses a hand-rolled 432-line English to Swedish dictionary where every UI string is its own key. Android got proper strings.xml and values-sv/ resources — but every Swedish value was lifted verbatim from the iOS dictionary, grep by grep, never invented. In one case an invented translation was caught and corrected during review, and after that the rule was absolute. The twist: the locale is a server-persisted profile field, not the device setting, so flipping the language in the profile calls AppCompatDelegate.setApplicationLocales and the whole app re-renders. Android's per-app locale machinery, driven by the backend.
OAuth in a Custom Tab, and the localhost trap
Android has no ASWebAuthenticationSession, so the PKCE flow runs in a Chrome Custom Tab: generate a verifier and state, persist them before launching because the browser can kill your process, open the authorize endpoint, and catch the redirect back with an intent filter on the singleTask activity — handling both onNewIntent and a fresh onCreate after process death. The state is validated on return before the code is ever exchanged.
fun signIn(context: Context, provider: String) {
val state = newState()
val verifier = Pkce.generateVerifier()
tokenStore.savePendingAuth(verifier, state) // survive process death
val authUrl = api.baseUrl.toUri().buildUpon()
.path(AUTHORIZE_PATH)
.appendQueryParameter("code_challenge", Pkce.challenge(verifier))
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("state", state)
.appendQueryParameter("provider", provider)
.build()
CustomTabsIntent.Builder().build().launchUrl(context, authUrl)
}First real login attempt: localhost can't be reached. The iOS simulator shares the host's network, so a local backend just works there. The Android emulator is its own machine, and the backend's OAuth pages redirect through localhost URLs that the Custom Tab resolves to the emulator itself. The usual fix, the 10.0.2.2 host alias, only helps the app's own HTTP calls, not the browser's. The real fix was adb reverse, which tunnels the emulator's localhost to the host's — after which the debug build could use plain localhost exactly like iOS, and every backend-generated URL resolves everywhere.
Two SSE clients
The shopping list stays in sync across a household through a long-lived SSE connection whose payload is never even read: the event just means "refetch". The port uses okhttp-sse with a read timeout of zero and the same exponential backoff reconnect loop as iOS, started when the shopping tab is active and torn down with it. Writes go the other way as optimistic local mutations with fire-and-forget POSTs — check an item and the UI flips instantly, the server catches up, the stream reconciles everyone else.
Chat is the second consumer: one stream per message, exposed to the ViewModel as a Flow of events via callbackFlow. The event names are a wire contract that was verified against the iOS switch statement line by line, because a couple of them are traps: two event names don't match the names of the payloads they carry, and one event's payload isn't JSON at all. Assume uniformity and the client quietly drops state it needs later, with no error anywhere.
Feature parity as a discipline, not a vibe
Full parity is easy to claim and easy to fumble. The port treated it as a process with four legs. First, source-first porting: no screen was written from the exploration summaries alone. Before each phase the corresponding Swift files were read in full — RecipeDetailView.swift before the detail screen, all 429 lines of ChatComponents.swift before the nine chat card renderers. Every Kotlin file names its iOS counterpart in a header comment, so future drift is auditable in either direction.
Second, behavior details carried on purpose. Parity lives in the small numbers, and each was ported deliberately rather than rediscovered later: suggestions are over-fetched so the Plan tab always has spares for swapping; onboarding paces its bubbles with a deliberate typing delay and caps how many times the suggestion set can be refreshed; the household servings math weights adults, teenagers and younger children differently. The shopping list's aisle view aggregates client-side rather than asking the server, and its rules — how quantities combine across recipes, when an amount is suppressed, how pantry items and unknown aisles are handled — are a line-for-line port, because that logic is the feature.
Third, verify against the live backend at every phase. Each of the ten build phases ended with the app installed on the emulator, signed in against a real local backend, and a screenshot reviewed. Not "does it compile" but: does the insight card render the italic runs in dark yellow, do real recipe images load, does an existing chat history decode into the right card types.
Fourth, port the absences too. Dead iOS code was catalogued during recon and explicitly not ported. And when a feature made no sense on Android — Sign in with Apple — it was removed outright rather than left as a stub, strings and docs included. Where Android idiom and iOS behavior conflicted, idiom won and behavior was preserved: swap-a-suggestion became a swipe action, nested Form pickers became a bottom sheet of Material chips, the segmented control became SegmentedButton. Same capabilities, native grammar.
Testing against a real backend
The iOS suite has an unusual discipline that the Android suite inherited wholesale: UI tests run against a real local backend with real data rather than mocks or fixtures. That imposes one iron rule — every test is read-only, or it restores whatever it creates. A test that plans a meal unplans it; a test that adds a shopping item deletes it; the rating test is conditional and skips when there is nothing unrated to rate.
@Before
fun seedTokenAndLaunch() {
// 1. Skip (don't fail) when the backend isn't running
assumeTrue("backend not reachable", pingBackend())
// 2. Seed the credential - instrumentation shares the debug app's process
app.container.tokenStore.save(token)
app.container.authManager.refreshFromStore()
// 3. Launch
scenario = ActivityScenario.launch(MainActivity::class.java)
}Each of those lines was earned. The credential is supplied per run as an instrumentation argument rather than committed anywhere, and is scoped to a development account. The refreshFromStore() call exists because the first full run failed all 12 tests: the Application, and with it the AuthManager's cached auth state, is constructed when instrumentation starts, before the setup seeds the credential, so every test sat politely on the sign-in wall.
The suite mirrors the iOS test classes one to one. The best test in either suite proves the entire realtime loop with zero UI interaction.
@Test
fun sseDrivenUpdateAppears() {
goToShopping()
val itemId = runBlocking { api.addShoppingItem("uitestsyncitem", "custom").id }
try {
// no taps: only the SSE stream can make this appear
waitForText("Uitestsyncitem", timeoutMillis = 30_000)
} finally {
runBlocking { api.deleteShoppingItem(itemId) }
}
waitForTextGone("Uitestsyncitem", timeoutMillis = 30_000)
}Getting to green was its own education in Compose testing. Espresso 3.6 crashes outright on API 36, so bump to 3.7 before believing any failure. LazyColumn only composes visible rows, so asserting on a section below the fold fails not because it is hidden but because it does not exist in the semantics tree yet — a class of failure XCUITest never taught anyone, because UIKit tables expose their whole model to accessibility. And a found node isn't a tappable node: the plan-toggle test kept timing out because the row it clicked was composed but sitting half under the app bar, so the injected tap landed on the toolbar.
The bugs a real user found
Shipping to a real user surfaced two bugs worth telling on themselves, because each encodes a genuinely Android-shaped lesson, and both were invisible to a casual "it looks right" check.
The first: deleting a meal from the plan didn't update the shopping list. The plan looked updated, but the server never received the delete. The swipe-to-dismiss row launched the removal from a LaunchedEffect scoped to the row itself; the optimistic local removal unmounted the row, which cancelled the coroutine at its first suspension point, before the request fired. The app then swallowed the CancellationException and looked perfectly healthy while being wrong.
// Wrong: dies with the row it lives on
LaunchedEffect(dismissState.currentValue) {
if (dismissState.currentValue == EndToStart) vm.removePlanned(row)
}
// Right: the mutation belongs to the ViewModel's scope -
// and the fix was applied to every mutation in the appDiagnosis was end to end: reproduce the swipe on the emulator, query the backend, observe the planned item still present server-side, then fix, swipe again, watch it disappear from both the plan and the shopping list, and restore the meal afterwards. The structural takeaway became a codebase rule: composable-scoped coroutines are for UI concerns only, anything that talks to the network runs in viewModelScope.
The second: chat responses wouldn't appear until you scrolled, and the typing dots vanished seemingly into nothing. The cause was one of Compose's deepest traps. ChatMessage had var fields, and the stream reducer mutated the instance in place before copying it into a new list. Compose's default snapshot policy compares old and new state structurally, and since the old list contained the very same now-mutated instance, old equalled new. Compose concluded nothing had changed and never repainted. Scrolling forced a re-layout, which is why the text "somehow" appeared. The dots weren't disappearing early; the text they had been replaced by just wasn't being drawn.
State objects are immutable; updates are replacements.
// ChatMessage: all vals. The reducer replaces, never mutates:
is ChatEvent.Text -> update(assistantId) {
if (it.stale) it.copy(text = event.text, stale = false)
else it.copy(text = it.text + event.text)
}
private fun update(id: UUID, transform: (ChatMessage) -> ChatMessage) {
messages = messages.map { if (it.id == id) transform(it) else it }
}The stale flag is itself a ported product detail: when the server restarts a response mid-stream, the old text isn't blanked, it is swapped the instant the retry's first token arrives, so the user never sees an empty bubble.
Emulator-life appendix
Environment quirks that cost real time, recorded so they never do again. Gradle 8.13 won't run on JDK 26, but Android Studio's bundled JDK 21 does, so org.gradle.java.home points at it. The AVDs weren't where the emulator looks, so the run scripts export ANDROID_AVD_HOME, and the AVD's real name came from its .ini rather than the directory name. A phantom offline adb device appears because another VM on the machine holds the port, so every script pins ANDROID_SERIAL to the first online device instead of trusting adb's list. And adb reverse is per-boot, so the run scripts re-establish the localhost tunnel on every launch.
All of it is wrapped in three scripts mirroring the iOS repo's workflow — one to boot, tunnel, build, install and launch; one for the device-pinned instrumentation suite; one for the tablet — plus a CLAUDE.md that documents every decision, quirk and command for the next session. Because the first lesson of this project was what happens when the docs and the code drift apart.
Where it landed
67 Kotlin files, a single module, full feature parity: server-driven onboarding with the meal-picking step, PKCE auth, the waitlist gate, the three tabs, recipe detail with per-ingredient substitution, cook mode with timers, streaming chat with nine rich card types and server-directed confirm actions, profile and household management with live locale switching, library, and recipe import. Thirteen instrumentation tests green against a live backend, with the same restore-what-you-touch discipline as the iOS suite. Live household sync proven both by test and by hand.
The iOS app took its shape from months of product iteration. The Android port took its shape from the iOS app — but its bones are entirely Android: Compose state driving Material components, ViewModels owning every mutation, structured concurrency, resource-based localization, and the platform's own answers (Custom Tabs, adb reverse, semantics-tree testing) to problems iOS never had to ask.