# How to Design an App with AI: Screenshots to Design System Source: https://docs.modaal.dev/ai-design Turn reference screenshots into a design direction, north-star screens, and a frozen design system the agent applies to every screen it builds. You provide references that point at what you like — the shadows, background colors, fonts, spacing, corner radii — plus anything else that matters to you. From those, the agent derives the actual values and shows them for your approval. It usually takes a few iterations to get right. The workflow produces three files that keep every future screen consistent: | File | What it holds | | - | - | | `design-system.md` | Design intent, rules, component specs | | `DesignTokens.swift` | The only place visual values live | | `AGENTS.md` | The rules and flow you want any coding agent to follow | Once these exist, every new screen comes out in the style you want — you'll still make some edits, just far fewer and smaller than starting from scratch each time. The example throughout: a co-parenting app with 4 screens — Home, Calendar, To-Do, Journal. Substitute your own screens. ## Stage 0 — Prepare (what to have ready for each stage) Write the rules for the agent — the flow you want it to follow, whether the flow described here or your own enhanced version. Include a guardrail so it never builds from an unfinished system: tell the agent to treat any file whose STATUS says `TEMPLATE` as empty — never reading, copying, or building from it — and to keep every unfilled value as bright magenta so a not-yet-filled system can't be mistaken for a design. (The `design-system.md` and `DesignTokens.swift` files themselves are created and filled at Stage 4 — keep them out of the project until then.) Screenshots of the app whose *visual language* you want, plus a line on what you like about it — for example the shadows, the colors, the typography, the corner radius, the spacing and whitespace, the depth or gradients, the icon style, and the overall feel or mood. Can't verbalize it? Write "not sure what I like — help me name it" and the agent proposes captions for you to confirm. You only need these when you're not happy with how the information is arranged — data in cards vs. rows, a calendar laid out a certain way, a summary section at the top. Either describe the arrangement you want in words, or grab a reference that shows it and point the agent at the specific *element* — "layout only: lay my tasks out as cards like this, not a list." Plain words describing what each screen shows. Example for Home: "greeting, next event with countdown, this-week custody strip, tomorrow's events." Or if you have a screenshot of your current app, that works perfectly. 1–2 things it must never look like: "no pastel baby-app vibes." ## Stage 1 — Direction (\~15 minutes) Send your references with a message like: ```text theme={null} Here are my style references. [images] Ref 1: I like the monochrome grayscale, gradient depth, accent colors used only for meaning. Anti: nothing neon, no default AI look. My app: co-parenting app, 4 screens (content below). Feel: calm, trustworthy, grown-up. First describe each reference back — what you see and what you'll take from it. Don't generate anything yet. ``` The agent describes each image back — correct anything misread *before* it contaminates the direction. Then it returns the direction: exact palette (light + dark), font proposal, radii, depth policy, and 2–3 signature elements. **Fonts.** Either you know one ("we use Nunito" — it's locked), or you say: "Propose 2–3 fonts that match the reference's feel, must be free to bundle in an iOS app, and show me the same screen title in each." You pick by looking, not by name. Check whether it's what you wanted — if not, keep iterating; if yes, move to the next stage. ## Stage 2 — First north-star screen (2–4 rounds) Start with the screen that carries your app's core meaning — in the example, Calendar (it carries the custody information). This first screen is your *north-star*: the exemplar every later screen follows, so it absorbs all the taste decisions. Give the agent a screenshot of this screen from your current app (if you have one) plus its content, and ask it to build it in the direction you chose. Then check: did it apply that direction, and does it work for you? When something's off, you have three moves: describe the fix in words (the dial vocabulary below), point the agent at a reference that shows what you want, or ask it to research how similar tools handle the screen and show you a few options to pick from. Keep style and layout separate — if the look is right but the calendar's arrangement isn't, that's a layout reference, not a style change. **Common pitfall — the agent styles from an empty template.** If the `design-system.md` and `DesignTokens.swift` templates sit in your project before they're filled, the agent may read their placeholder values and build from them — so your screens come out looking like the blank template, not your design. This is exactly what the Stage 0 guardrail prevents (mark unfilled files `TEMPLATE`, keep values magenta) — and why you keep them out of the project until Stage 4. ```text theme={null} Build my Calendar screen in this direction as an iOS HTML mockup, light and dark. Content: month grid; each day belongs to me or co-parent (that's the most important info); today marked; selected day shows its events below; add-event; bottom tabs: Home, Calendar, To-Do, Journal. Commit to the direction — go bolder than safe, I'll pull back. ``` Each round, give **reactions, not specs**. Pick from the dial vocabulary: * **color:** warmer / cooler / more muted / "bg must be white" * **weight:** lighter / heavier, thinner / thicker * **space:** denser / airier * **shape:** rounder / sharper, "like the reference" (the agent re-checks the image) * **depth:** flatter / more depth, gradient vs shadow * **type:** quieter / louder, bigger / smaller * **and mark what's RIGHT:** "tiles perfect, don't touch" Exit when you'd ship it: "approved, north-star 1." ## Stage 3 — Second north-star, with a layout reference (2–3 rounds) Do the second screen the same way as the first — same build-check-fix loop — but pick one that's structurally different (here, Home: hero card, lists, text) so you prove the style holds beyond a single layout. This time, lead with a layout reference: ```text theme={null} Now my Home screen. Layout reference attached — take ONLY the structure: [image] "layout only: the chronological timeline with the inline week strip; I also want an up-next hero like my current app (screenshot attached)." First describe the layout back as zones/hierarchy — no colors, no fonts. Don't generate yet. ``` Iterate on the structure — what's visible without scrolling, denser/airier, card vs plain list, section order. You can tweak the style here too, but don't fix it on this one screen: tell the agent to change it in the system so every screen inherits the change. And reuse components across screens — if the same element shows up in more than one place (say the week-day strip on Home and the day tiles on Calendar), it should be one shared component, not two look-alikes, so spacing, font sizes, and behavior stay identical everywhere. Exit: "approved, north-star 2. Lock it." ## Stage 4 — Freeze (one command) When you're happy with the style and ready to lock it, ask the agent to populate the design files with every decision from Stages 1–3: * `design-system.md` — the intent, rules, and component specs * `DesignTokens.swift` — the values, your single source of truth Two ways to set up the token file: take Modaal's Swift token files and edit them with your values, or ask the agent to create them from scratch. Either way, `DesignTokens.swift` becomes the one place your visual values live. The agent also saves the north-star screenshots. Then review: read `design-system.md` §1–2 (about 10 minutes) — check that the words describe *your* app's feel and the signature elements are the ones you approved, then commit the files. ## Stage 5 — Into the app (redesign in Modaal) When the design side is done, you hand your work to **Modaal** — step by step. Two passes: **swap the tokens first, then rebuild screen by screen.** Open **Modaal in plan mode** (the [Plan toggle](/articles/modes-and-prompts) on), ask it to redesign, and have it **propose the most efficient approach first** — before it touches anything. Bring three inputs: the **design-system files**, the **HTML mockups**, and the **north-star PNGs**. Ask Modaal to replace the app's existing tokens with your new ones, **one group per pass** rather than all at once: 1. **color** — primary · secondary · accent 2. **radius** 3. **spacing** 4. **typography** — families · sizes · weights With the tokens in, iterate **one screen at a time**: * Claude writes the spec + HTML + PNG * → Modaal builds the screen * → stuck? Claude re-describes that piece * → layout, grids & page structure back to Modaal Layout goes over as words, never as code. You don't phrase the handoff yourself — **have Claude write the Modaal prompt for you**, then iterate until you're happy. Every future visual change comes back as **screenshots for review, never code.** Expect one small adjustment pass on the first native render — fonts and curves come out slightly differently than in the HTML, usually better. ## Stage 6 — New screens, straight in Modaal Once your screens are in [Modaal](https://modaal.dev), you can create new ones directly there — they're built in your style automatically, and you can edit them right in Modaal. ## Keeping it consistent over time The system gives you consistency, but agents still drift — a hardcoded color here, a one-off component there. Every so often (say, every \~10 screens), ask the agent to audit: scan for hardcoded values that should be tokens, check text contrast in both light and dark mode, and confirm repeated elements still use the same shared component. Fix any drift by updating the token or the component, not the individual screens. The audit prompt and the smaller checks are on [Design prompts that work](/design-prompts), and [prompting agents for native mobile UI](https://modaal.dev/blog/prompting-agents-for-native-mobile-ui) covers which wording moves an agent's output. ## Summary — what you give at each stage | Stage | You must give | You never give | | - | - | - | | 0 | agent rules, refs + captions, content of your screens, anti-refs | exact hexes, spacing/radius numbers | | 1 | corrections to describe-back, palette gut-check | exact values | | 2 | screen content, dial reactions | pixel values (unless you want to) | | 3 | layout ref + element caption, structure tweaks | style feedback (system handles it) | | 4 | 10-min read of the doc, token-file choice | anything else | | 5 | inputs (design-system files, HTML, north-star PNGs), screenshot reactions | code review | | 6 | content of each new screen | style words, code | # Android API Levels: Versions, Codenames, and Device Coverage Source: https://docs.modaal.dev/articles/android-api-levels Every Android version with its API level, VERSION_CODES constant, codename, year, and live device coverage — plus how to choose the right minSdk. Every Android release carries an **API level** — the integer that build files name in `minSdk`, `targetSdk`, and `compileSdk`. The current release is Android 17 (API level 37, 2026), and the table below lists every version back to Android 1.0 with its API level, `VERSION_CODES` constant, codename, release year, and the share of active devices an app reaches if it requires that version. Coverage comes from [Statcounter GlobalStats](https://gs.statcounter.com/android-version-market-share/mobile/worldwide); the one-row-per-release format follows [apilevels.com](https://apilevels.com/). ## Which API level is which Android version? **Coverage measured August 2026** (Statcounter GlobalStats, mobile devices, worldwide). "Runs on" is cumulative: the share of devices on that version *or newer* — the reach of an app whose `minSdk` is that API level. | Android version | API level | `VERSION_CODES` | Codename | Runs on | Year | | - | - | - | - | - | - | | Android 17 | 37 | `CINNAMON_BUN` | Cinnamon Bun | 1.0% | 2026 | | Android 16 | 36 | `BAKLAVA` | Baklava | 27.0% | 2025 | | Android 15 | 35 | `VANILLA_ICE_CREAM` | Vanilla Ice Cream | 44.2% | 2024 | | Android 14 | 34 | `UPSIDE_DOWN_CAKE` | Upside Down Cake | 57.1% | 2023 | | Android 13 | 33 | `TIRAMISU` | Tiramisu | 72.0% | 2022 | | Android 12L | 32 | `S_V2` | Snow Cone | 72.0% | 2022 | | Android 12 | 31 | `S` | Snow Cone | 82.1% | 2021 | | Android 11 | 30 | `R` | Red Velvet Cake | 90.3% | 2020 | | Android 10 | 29 | `Q` | Quince Tart | 94.6% | 2019 | | Android 9 | 28 | `P` | Pie | 96.6% | 2018 | | Android 8.1 | 27 | `O_MR1` | Oreo | 97.6% | 2017 | | **Android 8.0** | **26** | **`O`** | **Oreo** | **98.3%** | **2017** | | Android 7.1 | 25 | `N_MR1` | Nougat | 98.5% | 2016 | | Android 7.0 | 24 | `N` | Nougat | 98.6% | 2016 | | Android 6.0 | 23 | `M` | Marshmallow | 99.3% | 2015 | | Android 5.1 | 22 | `LOLLIPOP_MR1` | Lollipop | 99.4% | 2015 | | Android 5.0 | 21 | `LOLLIPOP` | Lollipop | 99.9% | 2014 | | Android 4.4W | 20 | `KITKAT_WATCH` | KitKat | — | 2014 | | Android 4.4 | 19 | `KITKAT` | KitKat | 99.9% | 2013 | | Android 4.3 | 18 | `JELLY_BEAN_MR2` | Jelly Bean | — | 2013 | | Android 4.2 | 17 | `JELLY_BEAN_MR1` | Jelly Bean | — | 2012 | | Android 4.1 | 16 | `JELLY_BEAN` | Jelly Bean | 99.9% | 2012 | | Android 4.0.3–4.0.4 | 15 | `ICE_CREAM_SANDWICH_MR1` | Ice Cream Sandwich | — | 2011 | | Android 4.0 | 14 | `ICE_CREAM_SANDWICH` | Ice Cream Sandwich | — | 2011 | | Android 3.2 | 13 | `HONEYCOMB_MR2` | Honeycomb | — | 2011 | | Android 3.1 | 12 | `HONEYCOMB_MR1` | Honeycomb | — | 2011 | | Android 3.0 | 11 | `HONEYCOMB` | Honeycomb | — | 2011 | | Android 2.3.3–2.3.7 | 10 | `GINGERBREAD_MR1` | Gingerbread | — | 2011 | | Android 2.3 | 9 | `GINGERBREAD` | Gingerbread | — | 2010 | | Android 2.2 | 8 | `FROYO` | Froyo | — | 2010 | | Android 2.1 | 7 | `ECLAIR_MR1` | Eclair | — | 2010 | | Android 2.0.1 | 6 | `ECLAIR_0_1` | Eclair | — | 2009 | | Android 2.0 | 5 | `ECLAIR` | Eclair | — | 2009 | | Android 1.6 | 4 | `DONUT` | Donut | — | 2009 | | Android 1.5 | 3 | `CUPCAKE` | Cupcake | — | 2009 | | Android 1.1 | 2 | `BASE_1_1` | Petit Four | — | 2009 | | Android 1.0 | 1 | `BASE` | — | — | 2008 | The bolded row is the floor [Duet](/duet) ships. Duet — Modaal's cross-platform parity framework for native iOS and Android apps — sets `minSdk = 26` in every scaffolded Android target's Gradle files, with `compileSdk = 36` and `targetSdk = 36`, so a new Duet app installs on ≈98.3% of active Android devices. Horizontal bar of Android version market share from Statcounter GlobalStats for August 2026, segments from Android 17 through Android 8 and older, with a bracket marking that minSdk 26 reaches about 98.3 percent of devices ## How do you read this table? * **"Runs on" prices a `minSdk` choice.** It is the sum of Statcounter's monthly shares for that version and everything newer; requiring API 26 excludes the ≈1.7% of devices below it. * **The source measures usage, not installs.** Statcounter derives shares from web traffic across its network, so a phone used daily counts for more than one in a drawer. It is an independent proxy for the active install base, not Google's own distribution data. * **Rows marked "—"** sit below Statcounter's measurement floor for the month — each under 0.05% of traffic. * **Two rows can share a number.** Android 13 and 12L both show 72.0% because 12L's own share is 0.02%, which disappears in rounding. * **Codenames live on in the constants.** Releases are branded by number, and each still carries a dessert codename that surfaces in the [`Build.VERSION_CODES`](https://developer.android.com/reference/android/os/Build.VERSION_CODES) constants — `TIRAMISU` is API 33, `BAKLAVA` is API 36. * **Minor SDK releases share their version's row.** Android 16 and 17 also ship mid-cycle SDK levels (36.1, 37.1), documented alongside the [version-code constants](https://developer.android.com/reference/android/os/Build.VERSION_CODES); `minSdk` stays the integer API level. ## What minSdk should you use? Pick the lowest API level whose excluded devices you can accept, and let the table price the choice: as of August 2026, `minSdk 26` reaches ≈98.3% of active devices, `minSdk 29` reaches ≈94.6%, and `minSdk 31` reaches ≈82.1%. The three numbers in a build file do different jobs, defined by the [`uses-sdk` element](https://developer.android.com/guide/topics/manifest/uses-sdk-element): `minSdk` is the oldest version the app installs on, `targetSdk` declares which version's behavior changes the app has been tested against, and `compileSdk` is the API surface the code compiles against. Only `minSdk` costs you devices. `targetSdk` and `compileSdk` stay current regardless — Google Play [requires a recent `targetSdk`](https://developer.android.com/google/play/requirements/target-sdk) for new submissions and updates. Raising `minSdk` buys the ability to call newer platform APIs unconditionally — no runtime version checks, no compatibility fallbacks — and shrinks the device matrix you test. The cost is the coverage delta between the two rows, and the table states it exactly. ## Common questions For most new apps, API 26–29. Below 26 the added reach is under 2% of devices; above 31 you exclude nearly a fifth. Duet-scaffolded Android targets ship `minSdk 26` — the ≈98.3% row — and raise it per project only when a needed API demands it. `minSdk` gates installation — devices below it never see the app. `targetSdk` opts the app into the behavior rules of that version; the system applies compatibility behavior for newer devices. `compileSdk` only affects compilation. An app commonly compiles against and targets the newest API while keeping a much lower `minSdk`. Google Play filters the app out of search and its store page shows it as incompatible, per the [`uses-sdk` filtering rules](https://developer.android.com/guide/topics/manifest/uses-sdk-element). The system also refuses to install the APK directly, so sideloading doesn't bypass the floor. The measurement date is printed above the table — the numbers are from that month's Statcounter GlobalStats data, and the page is refreshed at least quarterly and whenever the Android SDK versions Modaal pins change. The chart regenerates from the same numbers as the table. Yes, as codenames: public branding uses the number, and each release's dessert name lives in its `VERSION_CODES` constant — Android 17 is Cinnamon Bun, Android 16 is Baklava. The last release marketed under its dessert name was Android 9 Pie in 2018. [Modaal](https://modaal.dev) scaffolds an app on the floor this page prices. [Set up the Android toolchain](/guides/setup-android) covers the SDK install; the components it pins live in the API 36 row above. ## Sources and further reading * [Statcounter GlobalStats — Android version market share](https://gs.statcounter.com/android-version-market-share/mobile/worldwide) — the coverage data (mobile devices, worldwide) * [apilevels.com](https://apilevels.com/) — the reference whose one-row-per-release format this table follows * [Build.VERSION\_CODES](https://developer.android.com/reference/android/os/Build.VERSION_CODES) — every API level and its constant, from the platform itself * [SDK Platform release notes](https://developer.android.com/tools/releases/platforms) — what each platform release added * [The uses-sdk element](https://developer.android.com/guide/topics/manifest/uses-sdk-element) — `minSdk` and `targetSdk` semantics and Play filtering * [Google Play's target API level requirement](https://developer.android.com/google/play/requirements/target-sdk) — the policy floor on `targetSdk` * [Android version history](https://en.wikipedia.org/wiki/Android_version_history) — community-maintained release chronology (Wikipedia) ## Read next The anatomy behind these numbers: the manifest, activities, Jetpack Compose, and the Gradle build that names minSdk. The one-banner install of the pinned SDK components — the API 36 platform, build tools, and your first emulator. # The Best Cross-Platform Frameworks in 2026 Source: https://docs.modaal.dev/articles/best-cross-platform-framework Flutter, React Native, Compose Multiplatform, KMP, MAUI, Ionic, Skip and two native apps compared: who draws the screen, where logic runs, what proves parity. The best cross-platform framework in 2026 depends on two questions most ranking pages never ask: who draws the screen — the platform's toolkit or the framework's own renderer — and where your logic runs — in a language runtime the app ships, or compiled against the platform. Answer those two and the field sorts itself; this page places every mainstream option on that grid, compares them side by side, and adds the axis none of the usual lists carry: what *proves* the two platforms behave the same. *Last reviewed: September 1, 2026. Framework version and capability claims were checked against each framework's official documentation on this date; the Ionic and Capacitor claims were checked again on September 18, 2026.* ## How should you compare cross-platform frameworks? Two-by-two grid placing cross-platform approaches by who draws the screen (platform toolkit vs framework renderer) and where logic runs (shipped runtime vs compiled). React Native, .NET MAUI and NativeScript top-left; Flutter and Ionic top-right; Compose Multiplatform bottom-right; Duet, two native apps, KMP shared-core practice, Rust cores and Skip bottom-left. The third question sits behind the grid: how do you know both apps behave the same? Two apps can behave identically **by convention** (two codebases kept in step by review and manual QA), **by construction** (one implementation, so there is nothing to compare — with the platform boundary exempt), or **by verification** (two implementations replay the same recorded fixtures, and CI compares the results byte-for-byte). Every approach in the table below is a position on the grid plus one of those three answers — [Should you share code between iOS and Android?](/articles/share-code-ios-android) develops the three-way distinction from the business side. ## How do the frameworks compare?
| | **[Flutter](/articles/duet-vs-flutter)** | **[React Native](/articles/duet-vs-react-native)** | **[Compose Multiplatform](/articles/duet-vs-compose-multiplatform)** | **[Ionic / Capacitor](/articles/duet-vs-ionic-capacitor)** | **.NET MAUI** | **KMP + MVI stacks** (Decompose, MVIKotlin, Circuit) | **Skip** | **Two native apps** | **Duet** | | - | - | - | - | - | - | - | - | - | - | | **Who draws the screen** | The framework — Impeller canvas on every target but web | The platform — Fabric composes native views | The framework — Skia canvas on iOS; on Android it *is* Jetpack Compose | A web view | The platform — handlers map controls to `UIButton` / `MaterialButton` | Per team — native UI per platform, or Compose | The platform — SwiftUI transpiled to Compose on Android | The platform, twice | The platform — SwiftUI and Jetpack Compose, authored per platform | | **Where feature logic lives** | Once, in Dart, on the shipped Dart runtime | Once, in JavaScript/TypeScript, on the shipped Hermes engine | Once, in Kotlin, compiled — no bundled VM | Once, in JavaScript, in the web runtime | Once, in C#, on the shipped .NET runtime | Once, in Kotlin, compiled | Once, in Swift — transpiled, or natively compiled via the Swift Android SDK | Twice, once per platform | Once, in Kotlin, compiled into both apps | | **What proves the two platforms agree** | One implementation; code behind platform channels sits outside it | One implementation; code in native modules sits outside it | One implementation, UI included | One implementation | One implementation; platform handlers sit outside it | Shared unit tests; no parity gate between the two apps' behavior | One source of truth; the transpiler is the guarantee, UI included | Code review and manual QA | Recorded fixtures replayed on both platforms; CI compares byte-for-byte | | **A native, perf-heavy island** | Drawing stays in Dart (shaders, painters); a camera, video or GPU island is a `Texture` or a platform view fed by Swift/Kotlin plugin code | Animations can run on the native driver; a native island is a Fabric Native Component plus a Native Module, written per platform | Drawing stays shared (Compose `Canvas`); on iOS a native island is a `UIKitView` built in Kotlin through Objective-C interop (Swift only for Swift-only APIs) inside a Skia-drawn screen, on Android it is `AndroidView` interop | A Capacitor plugin in Swift/Kotlin, with native UI presented over the web view, placed around it, or drawn under or into it and aligned with an HTML element — never as a DOM element | A handler customization or custom control reaching the `PlatformView`, per platform in C#; customizations are global unless the control is subclassed | Native already: ordinary Swift/Kotlin, with whatever seam the team's own architecture defines | Native already on iOS; on Android, hand-written Compose through `ComposeView` or `#if SKIP` blocks beside the transpiled UI | Native already, written twice | Native already: ordinary SwiftUI/UIKit and Compose/Android code; the reducer decides, a worker runs the pipeline, and the seam is recorded | | **Native code you write and compile for it** | Yes: Swift/Objective-C and Kotlin/Java in the host projects, compiled by Xcode and Gradle in the Flutter build; a plugin spares the writing, not the compiling | Yes: Objective-C++ and Kotlin/Java, compiled by Xcode and Gradle in the app build; an npm library spares the writing, not the compiling | Usually Kotlin only: Kotlin/Native's Objective-C interop builds UIKit views from Kotlin; Swift only for Swift-only APIs, compiled by Xcode | Yes: Swift/Objective-C and Java/Kotlin in the generated native projects, compiled in Xcode and Android Studio or by the CLI's build command | Platform-specific C# against the platform bindings, compiled by the .NET build per platform; no Swift or Kotlin | Yes: Swift in Xcode, Kotlin in Gradle, as in any native app | iOS: Swift in Xcode. Android: Kotlin is optional — `#if SKIP` blocks are Swift that transpiles to Kotlin; Gradle compiles either way | Yes, always | Yes, by design: Swift compiled by Xcode, Kotlin compiled by Gradle | | **SwiftUI and Compose in the island** | Yes, wrapped: `UIHostingController` inside an iOS platform view, `ComposeView` inside an Android one; a full-screen Compose activity is also documented | Yes, wrapped: `UIHostingController` and `ComposeView` inside a Fabric Native Component with Objective-C++ glue; with Expo, Expo UI builds UI directly with SwiftUI and Compose from React | Compose is the Android UI; SwiftUI on iOS through `UIHostingController` passed to `UIKitViewController`, written in Swift | Only as native views a plugin places over, around or under the web view, written in Swift or Kotlin | The handler surface is UIKit and Android Views through the .NET bindings | They are the interface, by construction | They are the interface: SwiftUI on iOS, Compose on Android, transpiled or hand-written | They are the interface, by construction | They are the interface: SwiftUI on iOS, Jetpack Compose on Android, by construction | | **Best fit** | UI-neutral apps with little per-platform variation, one team writing Dart end to end, desktop and web from the same code | Web/TypeScript teams, UI-neutral apps, the npm ecosystem, over-the-air updates | Kotlin-first teams that want one UI implementation with little per-platform variation, plus desktop | Existing web apps and teams; content-driven interfaces | .NET shops; enterprise LTS alignment | Kotlin teams assembling their own architecture | Swift-first teams that want Android from their existing SwiftUI source | Two independent platform teams; maximal per-platform investment | Rules-dense products that need native UI and a mechanical answer to "do the apps agree?" |
Approaches the table leaves as mentions: **NativeScript** (JS runtime driving native views — alive under the OpenJS Foundation, 9.0 announced November 2025, but absent from most current adoption lists) and **Lynx** (ByteDance's engine, open-sourced March 2025, powering TikTok surfaces; its ecosystem outside ByteDance is early). **Duet** is Modaal's cross-platform parity framework for native iOS and Android apps — the [overview](/articles/duet) covers the product; this page keeps it to one column among nine. ## Which approaches escape to native for a heavy screen? The island row splits the table three ways. **Native already** — Duet, two native apps, the KMP shared-core practice, and Skip on iOS: a Metal view, a camera pipeline or a video editor is ordinary platform code with no bridge, written per platform. **Escape through the framework's bridge** — Flutter (a [`Texture`](https://api.flutter.dev/flutter/widgets/Texture-class.html) or a [platform view](https://docs.flutter.dev/platform-integration/ios/platform-views), which the docs say "come with performance trade-offs"), React Native (a [Fabric Native Component](https://reactnative.dev/docs/fabric-native-components-introduction) plus a Native Module), Compose Multiplatform on iOS (a [`UIKitView`](https://kotlinlang.org/docs/multiplatform/compose-uikit-integration.html) built in Kotlin through Objective-C interop, or in Swift for Swift-only APIs), .NET MAUI (a [handler](https://learn.microsoft.com/en-us/dotnet/maui/user-interface/handlers/customize) reaching the `PlatformView`), Ionic/Capacitor (a [plugin](https://capacitorjs.com/docs/ios/custom-code) whose native UI is presented over the web view or drawn under it, as the [Google Maps plugin](https://capacitorjs.com/docs/apis/google-maps) does on Android): the island is written per platform anyway, plus the bridge that carries its parameters and results, and its logic leaves the shared implementation. **Drawn by the framework** — Flutter and Compose Multiplatform keep a drawing-heavy screen shared when it needs no platform API: GLSL shaders and painters in Dart, `Canvas` on Skia in Kotlin. The pairwise pages give each escape's mechanics; [Duet's side](/articles/duet-vs-flutter#what-if-one-screen-must-be-native-and-fast) is the same on all three: the reducer decides, a worker runs the pipeline, and the recorded seam is what CI compares. Whether the island means writing and compiling native code, per framework: **Flutter, React Native and Ionic/Capacitor** — yes, in Swift or Objective-C(++) and Kotlin or Java, compiled by Xcode and Gradle in the app build, unless an existing plugin or library covers the island, which spares the writing and not the compiling. **Compose Multiplatform** — usually Kotlin only, because Kotlin/Native's [Objective-C interop](https://kotlinlang.org/docs/native-objc-interop.html) builds UIKit views from Kotlin, with Swift reserved for Swift-only APIs. **.NET MAUI** — platform-specific C# against the platform bindings, [compiled by .NET per platform](https://learn.microsoft.com/en-us/dotnet/maui/platform-integration/invoke-platform-code); no Swift or Kotlin. **Skip** — Swift on iOS as usual, and on Android either Kotlin or Swift in `#if SKIP` blocks that transpile to Kotlin. **KMP shared-core stacks, two native apps and Duet** — yes, by design: Swift compiled by Xcode, Kotlin compiled by Gradle. Whether the island can be SwiftUI or Jetpack Compose rather than UIKit and Android Views: for **Duet, two native apps, KMP shared-core stacks and Skip** they are the interface already. **Flutter and React Native** get them wrapped — a SwiftUI view through [`UIHostingController`](https://developer.apple.com/documentation/swiftui/uihostingcontroller), Compose through [`ComposeView`](https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/compose-in-views) — inside a platform view or a Fabric Native Component; React Native's own guides mention Swift nowhere, while Expo, the framework its documentation recommends, adds Swift and Kotlin modules and [Expo UI](https://docs.expo.dev/versions/latest/sdk/ui/), "a set of components that allow you to build UIs directly with Jetpack Compose and SwiftUI from React". **Compose Multiplatform** is Compose on Android and takes SwiftUI on iOS through `UIHostingController` passed to `UIKitViewController`, written in Swift. **Ionic/Capacitor** gets them only as native views a plugin places over, around or under the web view; the [Capacitor page](/articles/duet-vs-ionic-capacitor#what-if-part-of-the-screen-must-be-native) measures a UIKit tab bar placed around one. **.NET MAUI**'s handler surface is UIKit and Android Views through the .NET bindings. ## Is the shared-core shape new? No. The bottom-left cell — platform draws, compiled shared core — is established practice under several names. Kotlin Multiplatform shared-logic practice is [officially supported by Google](https://developer.android.com/kotlin/multiplatform) and carries the mainstream case studies: Forbes (80%+ shared logic), McDonald's, Cash App (moved from shared JavaScript to KMP starting 2018), Google Docs on iOS, and 9GAG — which chose KMP after trying Flutter and React Native. The same shape exists with a Rust core: [Crux](https://github.com/redbadger/crux) captures effects as values executed by native shells — the same effects-as-data idea Duet uses — and Mozilla's [UniFFI](https://github.com/mozilla/uniffi-rs) and Signal's libsignal ship Rust cores under native apps at scale. One detail in JetBrains' own materials is worth reading as data: its shared-logic case-study list is long and mainstream, while its shared-UI list is short and recent — shared logic under native UI is the mainstream KMP shape. What lacks close prior art is the verification layer: a recorded behavior corpus that both platforms replay, byte-compared in CI, as the *gate* for parity. The KMP and Rust stacks above share code and share tests; none of them ships a mechanism whose job is to fail the build when the two apps' behavior diverges. That layer — not the shared core — is Duet's contribution, and it is why the table gives Duet a different "what proves agreement" cell than the stacks it otherwise resembles. ## What does the table not score? **App size.** Shipped runtimes have documented weight: Flutter's own FAQ measured a minimal release APK at \~4.3 MB (ARM32) / 4.8 MB (ARM64) and a minimal IPA download at \~10.9 MB — figures the FAQ dates to March 2021. Duet's compiled core measured +3 MB on the Android app of the shipped product Modaal's pages quote. React Native and Compose Multiplatform publish no equivalent official minimal-app figures, so this page quotes none. Capacitor publishes none either and ships no language engine of its own, since its code runs in the system web view; a lab build of a four-tab Capacitor 8 app measured 7,920 KiB as a simulator Release build, 4,480 KiB of it Capacitor's two frameworks, against 1,308 KiB for a SwiftUI build of the same screens, and the [Capacitor comparison](/articles/duet-vs-ionic-capacitor#what-ships-in-the-app-binary) gives the breakdown and the build times. **OS-release-day behavior.** A native interface inherits a new OS version's controls, behaviors and accessibility features on the OS's schedule. A framework-drawn interface adopts them when the framework does — Compose Multiplatform's FAQ presents the same fact from the other side, noting a canvas-drawn UI "will stay the same" across OS updates. Both readings are true: consistency for the canvas, currency for the native toolkit. A web view adopts a new OS design when its CSS does: for iOS 26's Liquid Glass, Ionic's 8.8 announcement states it is "not planning to ship an official Liquid Glass theme right now", and a Capacitor app takes the look from a community theme or its own CSS. Accessibility follows the same line — CMP maps its semantics to native iOS Accessibility Services (VoiceOver and Full Keyboard Access work; the mapping is maintained by the framework), while native toolkits get new platform accessibility features without a bridge. **Hiring and team shape.** The runtime rows map to talent pools: JavaScript/TypeScript (React Native, Ionic) is the largest; Dart expertise is mostly Flutter expertise; Kotlin rows draw from the Android population; Skip and Duet's iOS half draw from the Swift population. No numbers here age well enough to print — match the row to the team you can actually staff. **The iteration loop.** Hot reload is a real productivity difference: mature in Flutter (stable on the web target since 3.35), stable and default in Compose Multiplatform since 1.10.0. Compiled-core stacks, Duet included, iterate with per-platform previews and fast logic tests instead. ## Who owns each framework in 2026? Ownership moved enough in the last two years that most comparison pages are out of date: * **React Native** is governed by the **React Foundation** under the Linux Foundation (February 2026), with Amazon, Meta, Microsoft, Expo, Callstack and others as founding members. "Meta's framework" is no longer the governance story. * **Flutter** remains Google-led, with **Canonical named lead maintainer of Flutter Desktop** (May 2026) — stewardship is widening rather than narrowing. * **Compose Multiplatform** is JetBrains', extending Google's Jetpack Compose; the two companies co-anchor the Kotlin ecosystem. * **Ionic** has operated under **OutSystems since November 2022**. Ionic 9 (August 19, 2026) is the active release, with its maintenance end dates not yet set; Ionic 8's maintenance ends February 19, 2027. Ionic Framework and Capacitor stay MIT-licensed, while Ionic stopped selling its commercial products, the Appflow build and live-update service included, to new customers in February 2025, and Appflow shuts down on December 31, 2027. * **.NET MAUI** is Microsoft's, on .NET's LTS cadence (MAUI 10, November 2025). The precedent worth knowing when weighing any vendor framework: **Xamarin support ended May 1, 2024**, and its apps required migration. * **Skip** became free and open source (MPL-2.0) in January 2026 and releases actively. * **Duet** is Modaal's, open on GitHub with versioned contracts, in open beta. ## What do the usage surveys say? Quote surveys with their year, because the traps are real. Stack Overflow's 2024 survey — the last edition with a cross-platform category — put Flutter at 9.4% and React Native at 8.4% of all respondents, with Ionic at 2.5% and Capacitor at 1.8%; **the 2025 survey dropped the category**, so any page citing a 2025 Stack Overflow cross-platform figure is miscited. JetBrains reports Kotlin Multiplatform usage among its developer-ecosystem respondents doubling from 7% (2024) to 18% (2025). The widely re-cited Statista cross-platform chart's latest data year is 2023 (Flutter 46%, React Native 35% of cross-platform developers) — current pages re-cite it as if fresh; it is not. ## How do AI coding agents change the choice? Two properties matter, and they pull in different directions. Fluency: an agent has seen more JavaScript, React and Flutter than anything else, and one-language codebases are easy for it to move through. Verifiability: an agent compounds its own mistakes unless the project gives it mechanical gates, and this is where the frameworks now differ visibly. Compose Multiplatform 1.12.0 ships an experimental MCP server that lets an agent drive hot reloads, screenshot the app and inspect the semantic tree — agent-operable *UI tooling*. Duet's recorded corpus is agent-checkable *behavior gating*: a screen cannot wire to logic without a passing recording, and every push replays the corpus on both platforms — the argument in full is on [the testable-apps page](/articles/testable-android-apps#why-does-this-suit-ai-coding-agents). The [iOS architecture comparison](/articles/best-ios-app-architecture) scores the same axis across architectures rather than frameworks. ## Which should you pick? * **Interface-heavy, brand-driven product; one team end to end:** Flutter — or Compose Multiplatform if the team is Kotlin-first. * **Web engineering team; modest native-module needs:** React Native (start with Expo, per the project's own recommendation) — or Ionic if the product is close to a web app already. * **An existing web app to repackage:** Ionic/Capacitor. * **A .NET organization:** MAUI, with eyes open about the Xamarin precedent and the LTS calendar. * **A Swift team that wants Android from its existing SwiftUI source:** Skip. * **A Kotlin team that wants full control of its stack:** KMP with Decompose or MVIKotlin — you assemble the architecture and the test strategy yourself. * **A rules-dense product that needs native UI on both platforms and provable behavior parity:** Duet — the pairwise pages ([vs Flutter](/articles/duet-vs-flutter), [vs React Native](/articles/duet-vs-react-native), [vs Compose Multiplatform](/articles/duet-vs-compose-multiplatform), [vs Ionic Capacitor](/articles/duet-vs-ionic-capacitor)) argue each match-up in full. * **Two independent platform teams, by organizational design:** two native codebases, with manual QA priced in as the parity mechanism. ## Common questions This page prints no benchmark verdicts: the rendering models differ enough that microbenchmarks mislead, and the ranking pages that score "performance" cite each other rather than measurements. The structural facts that do hold: compiled rows carry no interpreter between your logic and the CPU; canvas renderers control their own frame pipeline; web views are bound by the browser engine. Measure your own workload if performance is the deciding axis. At launch, a single implementation is cheaper than two — that is arithmetic. Total cost depends on what the product needs later: per-platform code behind channels and modules, interface rework to fit platform expectations, and the QA budget for whatever the framework's parity answer is. The share-code page walks the cost side, including the measured \~40% dual-platform change premium in Duet's model — against a 100% baseline of doing everything twice. Xamarin's support ended May 1, 2024; .NET MAUI is its successor and holds the table column. Xamarin remains relevant only as the migration precedent quoted in the governance section. A progressive web app is a real option for content-driven products and dodges this page's question entirely — no second codebase, no framework row. The trade is capability and distribution: store presence, platform APIs, background behavior and platform-native interface expectations. Teams that need those come back to this table; teams that do not can stop at the web. Because the approaches do not compete on one axis. Flutter and React Native lead every adoption survey quoted above, and if popularity is your tiebreaker, that is data. This page's position is that the two grid questions and the parity question predict your costs better than a rank — and that the right column for a web team, a Kotlin team and a Swift team is simply a different column of the table. Duet projects are scaffolded by [Modaal](https://modaal.dev) — [starting a Duet project](/articles/duet#starting-a-duet-project) walks through the two template cards. ## Sources and further reading * [Flutter FAQ](https://docs.flutter.dev/resources/faq) and [Impeller](https://docs.flutter.dev/perf/impeller) — rendering, app-size figures, governance * [React Native documentation](https://reactnative.dev/docs/getting-started) and the [React Foundation announcement](https://www.linuxfoundation.org/press/linux-foundation-announces-the-launch-of-the-react-foundation) * [Compose Multiplatform and Jetpack Compose](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-and-jetpack-compose.html) and the [1.12.0 release post](https://blog.jetbrains.com/kotlin/2026/08/compose-multiplatform-1-12-0/) * [Kotlin Multiplatform on developer.android.com](https://developer.android.com/kotlin/multiplatform) and [JetBrains' case studies](https://kotlinlang.org/case-studies/) * [Ionic documentation](https://ionicframework.com/docs) and [Capacitor documentation](https://capacitorjs.com/docs) * [.NET MAUI documentation](https://learn.microsoft.com/en-us/dotnet/maui/) and the [Xamarin support policy](https://learn.microsoft.com/en-us/lifecycle/products/xamarin) * [Skip documentation](https://skip.tools/docs/) — the transpiled and natively compiled modes * [Crux](https://github.com/redbadger/crux) and [UniFFI](https://github.com/mozilla/uniffi-rs) — the Rust-core prior art * [Stack Overflow Developer Survey 2024](https://survey.stackoverflow.co/2024/technology) — the last edition with the cross-platform category * Escape-to-native routes, per framework: Flutter [platform views](https://docs.flutter.dev/platform-integration/ios/platform-views) and [`Texture`](https://api.flutter.dev/flutter/widgets/Texture-class.html); React Native [Fabric Native Components](https://reactnative.dev/docs/fabric-native-components-introduction); JetBrains' [UIKit integration](https://kotlinlang.org/docs/multiplatform/compose-uikit-integration.html); .NET MAUI [handler customization](https://learn.microsoft.com/en-us/dotnet/maui/user-interface/handlers/customize); Capacitor [custom native code](https://capacitorjs.com/docs/ios/custom-code); Skip [cross-platform topics](https://skip.dev/docs/platformcustomization/) * [Kotlin/Native interoperability with Swift/Objective-C](https://kotlinlang.org/docs/native-objc-interop.html), [.NET MAUI invoking platform code](https://learn.microsoft.com/en-us/dotnet/maui/platform-integration/invoke-platform-code) and the [Capacitor workflow](https://capacitorjs.com/docs/basics/workflow) — which language the island is written in and what compiles it * Apple's [`UIHostingController`](https://developer.apple.com/documentation/swiftui/uihostingcontroller), Android's [`ComposeView`](https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/compose-in-views), [Expo Modules API](https://docs.expo.dev/modules/overview/) and [Expo UI](https://docs.expo.dev/versions/latest/sdk/ui/) — how SwiftUI and Compose enter a framework-drawn or React-driven screen * [The Duet framework on GitHub](https://github.com/modaal-agent/duet) — both flavors, the recording toolchain and the versioned contracts ## Read next The canvas-and-runtime model against the native-UI shared-core model, in depth. Both draw native views — the comparison is where logic runs and what checks the boundary. Same language decision, opposite UI decision — the nearest relative in the field. A web view against two native toolkits, with a four-tab lab build measuring what a native look costs in Capacitor. The same comparison discipline one level down: TCA, RIBs, VIPER, MVVM, MVC and Clean, scored. The same comparison one level up: the app builders, not the frameworks they emit, on modaal.dev. # Best iOS App Architecture: TCA, RIBs, VIPER, MVVM, Clean Source: https://docs.modaal.dev/articles/best-ios-app-architecture TCA, RIBs, VIPER, MVVM, MVC and Clean Architecture scored on testability, team size, onboarding, cross-platform reach and AI-agent fit — with cited sources. There is no single best iOS architecture — there is a best fit per app size, team size and risk profile, and the honest way to choose is an axis-by-axis comparison. This page scores the six mainstream choices — MVC, MVVM, Clean Architecture, VIPER, RIBs and TCA (the Composable Architecture) — plus [**Duet**](/duet), Modaal's cross-platform parity framework for native iOS and Android apps, on the axes the practitioner literature actually argues about, and adds the two axes that literature does not score: whether the shape survives a second platform, and how well an AI coding agent can work inside it. *Last reviewed: September 1, 2026. Library versions and repository states were checked against each project's official repository on this date.* ## How do the architectures compare?
| | MVC | MVVM | Clean Architecture | VIPER | RIBs | TCA | Duet | | - | - | - | - | - | - | - | - | | **Complexity & boilerplate** | Minimal | Low | High | High | High | Medium–high | Medium | | **Development speed** | Fastest first screen | Fast | Slower | Slow | Slow | Medium | Medium | | **Testability** | Low — logic accretes in controllers | Medium — ViewModels test well, flow unenforced | High — use-cases isolate logic | High — per-module protocols | High — best-in-class seams | High — exhaustive TestStore | High — recorded scenarios replay in CI | | **Separation of concerns** | Weak in practice | Per-screen | Strong on paper, held by review | Strong, with per-screen ceremony | Best-in-class | Strong | Strong — the seams are scaffolded | | **Pays off at** | 1–2 devs | 2–6 devs | 6+ devs | 8+ devs | 10+ devs | 4+ devs | Any size with an agent; 2+ devs by hand | | **SwiftUI fit** | UIKit-era; SwiftUI dissolves the "C" | Natural — `@Observable` is the pattern | Neutral | Poor — UIKit vintage | Neutral — UIKit-first history | Excellent — per-field observation | Native SwiftUI shells | | **Onboarding** | Immediate | Fast | Slow — layer map first | Slow | Slow | Slow — real concept load | Medium | | **Over-engineering risk (small apps)** | None | Low | High | High | High | Medium | Medium | | **Cross-platform reach** | None | Convention only — nothing verifies a port | The layer map ports; every layer is re-implemented | None built in | Proven at Uber as two parallel implementations | Swift-only | Built in — one compiled core, both platforms, gated | | **AI-agent fit** | Weak — no mechanical gates | Medium — testable units, seams unenforced | Medium — rules live in review, not the build | Medium — many files per screen | Medium — strong seams, heavy ceremony | Good — exhaustive tests gate changes | Built for it — recordings gate each step |
Two disclosures about this table. The gradations are this page's own synthesis: the criteria come from the practitioner sources cited throughout — a [Medium survey of the patterns](https://medium.com/@sharmapraveen91/modern-ios-architecture-patterns-and-best-practices-5320e2d9d1aa) (per-app-type fit guidance and per-pattern characterizations), the [tailec/ios-architecture sample collection](https://github.com/tailec/ios-architecture), and the [r/iOSProgramming thread on scalable architecture](https://www.reddit.com/r/iOSProgramming/comments/194o9w5/) — all commentary, none of which publishes a scored matrix like this one. And the Duet column is written by Duet's vendor; the criteria definitions and the concessions below are how this page keeps that column honest. The criteria rows encode what the r/iOSProgramming thread's practitioners argue about. Its most-quoted definition, from user danielt1263: "A good architecture does two things. 1. Separates logic from effects (user interactions, network calls and DB reads/writes) so that the logic is easily testable. 2. Separates features from each other (as much as possible) so that it's easy to add/remove/update a feature without breaking other features." The table's testability and separation rows score exactly those two properties. ## TCA: the closest relative to Duet The [Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture) (Point-Free; 1.26.2 as of August 2026, \~15k stars) is the strongest of the six at making state, actions and effects explicit — and it is the architecture Duet learned from. The mapping, concept by concept: | TCA | Duet | | - | - | | State struct with `@ObservableState` | Serializable feature state — same role | | `Action` enum | Sealed action set — kept as-is | | Reducer body — pure state transitions | A pure reducer per feature — kept, with attribution | | `Effect` — an opaque async closure | Effect *values* — equatable data a worker performs | | `@Dependency` injection | Environment protocols behind the effect seam | | Exhaustive `TestStore` | A recorded scenario, replayed on both platforms in CI | | `scope` and the composition operators | Composition in shells and builders, not operators | | Cancel-in-flight effect identity | Kept — cancellation expressed as data | The two departures have one reason: a recording must be able to assert effects. An opaque `Effect` closure can be run but not compared, so Duet's reducers return effect descriptions — data with equality — and the fixture asserts exactly what was requested, in what order. The same requirement moves dependencies from `@Dependency`'s resolver into named environment protocols: what the logic needs is part of its recorded contract, and each platform supplies its implementation as a [worker](/articles/duet-glossary#worker). **What TCA does better.** Its composition operators are a genuinely powerful way to build big features from small ones, and Duet has no equivalent — composition happens structurally, in shells. Its dependency system (`swift-dependencies`) is more ergonomic than hand-named environment protocols. Its `TestStore` is mature, exhaustive by default, with a documented non-exhaustive mode for integration tests. And its observation is finer-grained: since TCA 1.7, views observe the individual state fields they read, so a change re-renders only the views touching those fields — Duet's shells re-render per whole-state emission and lean on the platform's diffing below that. **What TCA adds to the build.** In Modaal's build-lane measurement on a toy feature package, adding TCA brought 14 dependency packages into the build and moved a cold `swift test` from 7 seconds to 55 seconds. That is a build-and-CI cost, not a runtime-performance claim, and a toy-scale number — but the dependency surface is also what one thread commenter meant by disliking that TCA "relies heavily on a third party library": the architecture arrives as a substantial external dependency, where MVC and MVVM arrive as conventions and Duet arrives as scaffolded project structure plus a small runtime of its own. **Choose TCA over Duet** for a Swift-only product that wants maximal in-language power — composition operators, the dependency system, per-field observation — and does not need a second platform verified against the same behavior. TCA is Swift; Duet's reducer compiles to both platforms and its recordings gate both apps. ## RIBs and VIPER: the module-boundary lineage [VIPER](https://www.objc.io/issues/13-architecture/viper/) (Mutual Mobile, published on objc.io in 2014) applied Clean Architecture's separation to iOS screens: View, Interactor, Presenter, Entity, Router, each behind a protocol. [RIBs](https://github.com/uber/RIBs) is Uber's descendant — Router, Interactor, Builder, with optional Presenter and View — built for apps where the business tree, not the screen stack, drives structure, and proven at Uber scale as two parallel implementations, one per platform ([the rider-app engineering post](https://www.uber.com/blog/new-rider-app-architecture/) tells that story). The iOS library now lives in its own actively maintained repository, [uber/RIBs-iOS](https://github.com/uber/RIBs-iOS) (1.1.0, July 2026), still built on an RxSwift backbone; the original repo continues as Android-only. The concession leads here, because it is the lineage's whole argument: **these architectures' protocol seams are best-in-class.** Every boundary is a protocol; everything is mockable; a team of thirty can work one app without treading on each other. RIBs adds a real innovation Duet inherits in spirit — the business-logic tree owns navigation, and screens are leaves, not owners. The cost is per-screen ceremony. In Modaal's conversion work, a literal RIBs leaf shell — the files a screen needs before any feature logic exists — measured about 101 lines across 6 declarations, against a 28-line hand-rolled equivalent for the same screen; that overhead recurs on every screen the app adds. Duet's position is that most of what the seams buy — mockability, isolation, navigation owned by logic — is bought instead by the pure-reducer-plus-effects shape and the recorded contract, without the per-screen protocol suite; VIPER's presenter layer, specifically, is replaced by state projection. No internal measurements exist for VIPER itself — the cited sources above carry its scoring. **Choose RIBs** when a large team needs enforced module ownership on a business-tree-shaped app and accepts the ceremony; its seams remain the reference point. Teams searching for a "RIBs successor" for a two-platform product are the readers Duet's row in the table addresses: the same tree-owns-navigation instinct, with the parallel-implementation problem replaced by one verified core. ## MVC and MVVM: the starting points **MVC** is Apple's original template shape: fastest first screen, no structural contract. Its documented failure mode — the Medium survey calls it out as the pattern's major drawback — is the massive view controller: logic accretes in the controller because nothing says where else it goes. For a prototype or a 1–2 person app that will stay small, that trade is fine, and the practitioner thread's top-voted advice points the same way. **MVVM** is the reactive upgrade SwiftUI made near-default: views bind to an observable ViewModel, and — as the survey puts it — the ViewModel tests well because it holds no UI. With `@Observable`, SwiftUI is MVVM-shaped out of the box. Two limits keep it mid-table. Nothing enforces unidirectionality or where effects live: each ViewModel author decides, so testability degrades screen by screen as shortcuts accrete (one thread commenter: moving logic into the view "makes it completely untestable"). And nothing verifies a port — an Android twin of an MVVM app shares an acronym, not behavior. The thread's most-voted comment is still the right small-app default: "MVVM is a pretty good default to use until you have a compelling reason to want something fancier." This page agrees, and the table's rows are the compelling reasons enumerated. For the Android-side view of the same judgment, see [how Duet compares to a hand-rolled ViewModel](/articles/testable-android-apps#what-does-a-blank-project-leave-open-that-duet-fixes). ## Clean Architecture: layers held by convention [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) (Robert C. Martin, 2012) gives the strongest separation on paper: entities, use-cases, interface adapters, frameworks, with dependencies pointing inward. On iOS it produces highly testable use-cases and the highest ceremony of the six, and its enforcement mechanism is the one to be clear-eyed about: layer boundaries live in convention and code review — no build gate fails when a use-case imports a framework type. Teams that keep Clean clean do it with discipline and lint rules they write themselves. Duet's mapping: the use-case layer corresponds to the reducer plus its environment; the Dependency Rule corresponds to the scaffolded rule that reducers take no platform imports. What Duet adds is an executable form of the contract — [recordings](/articles/duet-glossary#fixture) that replay in CI — and what Clean keeps that Duet gives up is freedom: any per-layer idiom, any binding style, any test strategy. That freedom is why Clean fits large single-platform teams with strong review culture, and why the thread's enterprise-scale defenders argue for it in exactly those terms. ## Is RxSwift or Combine an architecture? No — reactive programming is a binding substrate that several of the architectures sit on, and treating it as a competing architecture (the survey covers "Reactive Programming" as a seventh pattern) conflates two independent decisions. The evidence is the [tailec/ios-architecture](https://github.com/tailec/ios-architecture) collection — a 2019–2022 sample archive, UIKit/RxSwift vintage, and still the clearest demonstration that the *same* MVVM ships with four different binding styles (closures, subjects, observables, functions). Pick the architecture first; the binding style is a separate, smaller decision. The substrate landscape today: [RxSwift](https://github.com/ReactiveX/RxSwift) remains actively released (6.10.2, March 2026, with 2–3 releases a year tracking new Xcode and Swift versions) and still underpins RIBs-iOS; Combine ships with no deprecation flags in Apple's documentation, while community commentary places Apple's investment in Swift Concurrency and Observation. Duet's stance on the substrate: streams live in adapters at the edges; feature logic consumes plain values and returns effect data. A subscription bag inside logic is the one shape that blocks conversion to a shared core — which is why the Combine-to-coroutines operator mapping matters at the edges and nowhere else. ## Which architecture should you choose? * **A prototype or an app that stays small:** MVC or plain SwiftUI-MVVM. The ceremony rows of the table are costs you would pay for nothing. * **A growing single-platform app with a few engineers:** MVVM, upgraded with discipline where it hurts — or TCA when deterministic state, exhaustive tests and in-language composition are worth its concept load and dependency surface. * **A large single-platform team needing enforced ownership:** RIBs (business-tree apps, maximal seams) or Clean (strong review culture, maximal freedom inside layers). VIPER today is mostly the maintained-codebase case; new adoption has largely moved to its descendants. * **A product where Android exists or is coming:** this is the axis that reorders the table. MVC, MVVM, Clean and VIPER offer a second platform nothing but conventions; RIBs offers a proven pattern implemented twice; TCA is Swift. Duet was built for this row — one Kotlin core compiled into both apps, with recorded fixtures as the [cross-platform behavior gate](/articles/duet#what-you-get) — and [whether to share at all](/articles/share-code-ios-android) is its own decision, upstream of this page. * **A codebase an AI agent will build with you:** weight the AI-agent row. An agent is exactly the collaborator that benefits from mechanical gates between steps — the argument, with the scaffold's step order, is made in [why this suits AI coding agents](/articles/testable-android-apps#why-does-this-suit-ai-coding-agents). ## Common questions The thread's onboarding reports are real — one commenter: "I am STRUGGLING to understand TCA at my new job. All the concepts and functions are so foreign to me." The concept load pays for exhaustive testability and composition; on a small app with simple state, that is paying for capacity you will not use, which is what the table's over-engineering row scores. On iOS, MVP's niche was absorbed: its presenter idea lives on inside VIPER and RIBs, and MVVM took the plain-screen case once bindings became first-class. The sample archives still carry MVP implementations, but current iOS literature and hiring treat it as historical rather than a live option. SwiftUI answers the binding question — views observe state natively — which is why MVVM's fit score is high and VIPER's is poor. It does not answer where effects live, who owns navigation, what a test can prove, or what happens on a second platform. Those four questions are the table's remaining rows, and they are architecture questions regardless of toolkit. The unidirectional reducer shape exists across the Kotlin world as MVI (and in libraries like MVIKotlin and Decompose), but TCA itself is a Swift library. Duet is one answer to the search behind this question: the TCA-derived reducer shape written once in Kotlin, compiled into both apps, and verified with recordings rather than ported by hand. Yes, and large apps usually do — a RIBs tree with MVVM leaves, or a Clean core behind MVC screens. The cost is per-boundary translation and a harder onboarding story. The one mix that reliably fails is architecture-per-author in the same layer, which is the MVVM drift mode the table's separation row already prices in. Modaal scaffolds Duet projects with this page's trade-offs already decided — [modaal.dev](https://modaal.dev). ## Sources and further reading **Authorities — each architecture's own source:** * [swift-composable-architecture](https://github.com/pointfreeco/swift-composable-architecture) — Point-Free's TCA repository and documentation * [uber/RIBs](https://github.com/uber/RIBs) and [uber/RIBs-iOS](https://github.com/uber/RIBs-iOS) — the two RIBs repositories; [Engineering the architecture behind Uber's new rider app](https://www.uber.com/blog/new-rider-app-architecture/) — the introducing post * [Architecting iOS apps with VIPER](https://www.objc.io/issues/13-architecture/viper/) — the origin article, objc.io issue 13 * [The Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) — Robert C. Martin's post; the book expands it * [Apple's Observation documentation](https://developer.apple.com/documentation/observation) — the substrate under current MVVM and TCA observation * [RxSwift](https://github.com/ReactiveX/RxSwift) — the reactive substrate under RIBs-iOS and pre-Combine MVVM **Commentary — shaped this page's axes, labeled as such:** * [Modern iOS architecture patterns and best practices](https://medium.com/@sharmapraveen91/modern-ios-architecture-patterns-and-best-practices-5320e2d9d1aa) — per-app-type fit guidance across six patterns * [What is the best architecture to build scalable and robust apps?](https://www.reddit.com/r/iOSProgramming/comments/194o9w5/) — the practitioner criteria this page's table rows encode * [tailec/ios-architecture](https://github.com/tailec/ios-architecture) — working samples of MVC, MVP, four MVVM binding variants and VIPER (a 2019–2022 archive) ## Read next The same reducer-and-recordings argument on the Android side, with code. One level up: the framework survey this page's cross-platform row points into. The product form of the table's last row — what the scaffold fixes and what it asks. For teams on a RIBs-family codebase today: the per-feature route to a shared core. # Migrating a CombineRIBs App to Duet Source: https://docs.modaal.dev/articles/combineribs-to-duet How an existing CombineRIBs project gains a shared Kotlin core and an Android app, one feature at a time, with no flag-day rewrite and no feature freeze. A Modaal project created from the `ios-ribs` template is a CombineRIBs app: Routers, Interactors, Builders and Components, iOS only. This page is the route from there to the shape a **Production app** for **Android + iOS** scaffolds — a shared Kotlin core, a native Android app, and iOS shells consuming the same compiled logic. The migration is **per feature, revertible, and never a flag day**. Duet-shaped features live beside CombineRIBs ones inside one app for the whole migration, the iOS app keeps building and shipping throughout, and you can stop between any two features and keep everything banked so far. **Prerequisite.** The tools that graft the Kotlin core and the Android app, and building both platforms, are part of [Pro](/credits-and-plans); the plans are listed on [modaal.dev/pricing](https://modaal.dev/pricing). ## Does this apply to your project? | Your project came from | This page | | - | - | | **Production app**, the CombineRIBs template (`ios-ribs`) | Yes — this is the route. | | **Watch + iPhone** (`watchos-ios-ribs`), the iPhone half | Yes. The watch target is MV and is not touched. | | **2D game** (`ios-ribs-spritekit`) | Ask first. No Duet flavor scaffolds a SpriteKit scene host, so the render surface needs its own answer before step 0. | | **Quick prototype** (`ios-mvvm`) | No. MVVM has no migration path — see [Starting a new project](/articles/new-project#choosing-a-template). | | Either **Production app** combination | Not needed. A Duet Swift-flavor project reaches Android through [its own convergence](/articles/duet#adding-android-to-an-iphone-only-project), which skips most of what is below. | **How to tell what you have:** a CombineRIBs tree has `import CombineRIBs` in its sources and a `RIBs/` directory per feature, and it has **no** `parity/manifest.yaml` at the repository root. A Duet tree has that file and no CombineRIBs import. ## What you end up with The finished tree is shape-equivalent to one a fresh **Production app for Android + iOS** scaffold emits: `src-kmp/` holding the Gradle plane, the core modules, one module per feature and the Android app; `src-ios/` shells consuming the assembled core; `parity/` holding the manifest, the feature specs and the fixtures; and `tools/duet`. No `import CombineRIBs` anywhere, and no migration package left. The [Duet overview](/articles/duet) describes that destination in full. This page is how an app already in flight gets there. ## Three properties that shape everything else Converted features and CombineRIBs features live side by side inside one app for the entire migration. There is no branch, no freeze, and no big-bang cutover. A feature converts end to end before the next one starts, and each conversion is revertible on its own: deleting the new module and restoring the old interactor body is a clean revert. Halt after any feature and keep what you have banked — the tests, the determinism, one DI idiom, and a running Android app. The end state is not all-or-nothing. ## Before you start Two questions worth answering out loud, because they change how much work follows each converted feature rather than which steps you take: A shared core you can build on, a rough build to try on a device, and a store-shipped Android product all walk the same steps. What differs is how much Compose shell and platform work follows each crossed feature. A running Android app on an emulator arrives early — at step 2, before any feature is converted. A *shipped* Android product arrives feature by feature, and its calendar time scales with your feature count. Set that against the date before the audit rather than after it. ## What are the stages of the migration? Before anything is scanned or generated, the repository's own agent documents are edited: `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md` and any always-applied mirrors of them (`.cursor/rules/*.mdc`, `.github/copilot-instructions.md`). The edit records the migration decision and one rule: **from today, every new feature is authored in the target shape — a reducer plus a thin shell — never as a new Router.** Those files instruct every future session in the repository. One left unedited keeps telling the next session to build a Router, which is why this edit comes before the audit rather than after it. A static scan of the app produces three artifacts inside your project: * **The readiness report** — a census of the distance to the target shape: routers, listener protocols, legacy workers, timing hits, vendor-SDK quarantine breaches, DI and view-binding idioms. * **The migration ledger** (`.modaal/duet-migration-ledger.json`) — one row per surface, each with a status: `legacy`, `in-progress`, `converted`, `retired`, `blocked` or `wont-convert`. Re-running the audit merges into the same file, so the progress figure is always current. * **Spec drafts** (`.modaal/duet-migration/spec-drafts/.md`) — a statically extracted head start on each feature spec. Treat the counts as pointers rather than verdicts: it is a text scan, and every extracted row is a candidate to verify against the source. One call puts the whole Duet substrate into the existing tree: the Kotlin core plane under `src-kmp/` (Gradle wrapper, the shared-service, theming and umbrella modules, the per-feature module plane), the Android app shell, and the parity workspace (`parity/manifest.yaml`, `parity/fixtures/`, `parity/feature-specs/`, the pinned `tools/duet` wrapper). It also emits one synthetic `counter` feature with the scenario test its fixtures are recorded from. What it does **not** touch: your legacy app's sources and tests. It declares the two migration packages in the iOS app's `xcodegen.yml` without linking them, and appends a section to the agent documents and a block to `.gitignore`. **Exit gate:** the Android app builds and launches on an emulator with the synthetic feature passing from a recorded fixture, and the legacy iOS app builds and runs unchanged. Never hand-build this. An Android target, a Gradle plane or a Kotlin source tree added by hand is missing the migration marker in `.modaal/project.json` that routes every later call, along with the manifest rows and module wiring. Immediately after the graft's gate, a second call links the assembled Kotlin core into the iOS app's build: the consumer package, the assemble build pre-actions, the boundary test lane, and the assemble script. Nothing about the running app changes — it still runs its CombineRIBs code. What this buys is that every later feature flip is feature work with no build wiring left to add. **Exit gate:** the core assembles, the legacy app builds and runs unchanged with the framework linked, and the boundary replay passes. Every feature gets a row in `parity/manifest.yaml` and an order in the ledger. Three rules set that order: * **Riskiest logic first** — the flows whose untested behavior worries you most: auth, save paths, navigation decisions. Conversion is how a feature *gains* exhaustive tests, so spend it where tests are most needed. It is also the same cost to convert either way. * **Leaves before the spine** — self-contained screens before the navigation tree that hosts them. * **Navigation staged last.** This is the bulk of the migration and it interleaves freely with normal product work. [The next section](#what-crossing-one-feature-looks-like) walks through one crossing. Once a feature's logic is in the Kotlin core, its remaining CombineRIBs shell is re-homed onto Duet types, and the migration package's dependency edge is deleted. [The finish line](#the-finish-line) lists what has to hold. Steps 3 and 4 interleave with ordinary feature work. You are not asked to freeze the product for the duration — that is the point of converting one feature at a time. ## What crossing one feature looks like A feature's logic is authored **twice, in one order, inside one window**: Swift first, then Kotlin, and the Swift copy is deleted at the end of the crossing. The two halves have different oracles, which is why they are two changes rather than one. ### First half: the Swift twin, inside the legacy app One page, platform-agnostic: state fields, action cases, transitions, effect payloads and their seams, delegate events, fixtures. The extracted draft from step 1 is the starting point, verified row by row against the source. The legacy interactor is the behavior source; its existing Quick/Nimble spec is the acceptance oracle. Deliberate convergences get written down here rather than ported silently — behavior that varied by build config (`#if DEBUG` aliases, scheme shortcuts) does not survive into a reducer, and incidental platform-parser tolerances are converged to one chosen, fixture-pinned rule. `State`, `Action`, the reducer, `EffectPayload`, and an `Environment` protocol. The reducer is pure: no platform imports, no vendor SDKs, no `Date()`, no `UUID()`. Time and identity arrive as action payloads or seeded state. The legacy spec is ported **1:1** onto the deterministic test store — every `it` becomes a scenario step sequence. Behavior differences found here are findings about the old code, not test bugs. Then the fixtures are recorded and committed. **The mutation drill runs here and is not optional:** break one reducer guard on purpose, confirm the fixture replay goes red, revert. A drill that stays green means the harness is not wired to anything. The Interactor keeps its class name and its RIBs lifecycle and loses its logic. A finished shell does exactly three things: binds presenter reports to store actions, projects state onto the existing view state, and forwards delegate events to the old listener. Builder, Router and View stay untouched, which is what keeps the conversion revertible. The `Environment` protocol is the feature's only door to the platform, implemented live by adapters over your existing services and for tests by one hand-rolled fake. A feature whose only environment duty is the delegate sink has no port, no worker and no fake — that is a valid shape, not an omission. At the end of this half the feature still ships on iOS, exercised by your app's own test suite, and the extraction is proven to have preserved behavior. ### Second half: the Kotlin twin, and Android The feature's manifest row now declares both a `swift:` and a `kotlin:` source. That dual state is **the window**, and it is where the parity lints run. The fixtures recorded from the Swift twin replay byte-identically from the Kotlin one. That is what "the port preserved behavior" means, and it is a different oracle from the one the first half used. A thin Compose shell and the feature's platform workers, app-side. The feature is not crossed until the Android app runs it. The manifest row flips to `kotlin:`-only and the Swift copy is deleted in the same change. The iOS app runs the feature from the assembled core — the build wiring for that landed back at step 2b. **The acceptance test is the same throughout: the fixtures do not change.** A behavior-preserving move shows metadata-only churn — scenario source locations, step labels — and zero state or effect bytes. Behavioral churn during a port is a finding to investigate, never a fixture to edit. **On this route, each feature's window closes at its flip**, because the iPhone app moves onto the shared core and the Swift twin is deleted. Keeping both copies permanently is the [Duet Twins](/articles/duet-twins) architecture, in development, where every behavior change edits both reducers. ## What happens to each piece of your code Every CombineRIBs shape has exactly one fate. The Dependency / Component / Builder triple is the destination, not residue — Duet composition uses the same triple at every level. | Legacy shape | Fate | | - | - | | `Dependency` protocol | **Port unchanged.** It is already the Duet shape. | | `Component` | **Port.** The feature's environment factory becomes a Component member. | | `Builder` / `Buildable` | **Port, minus resolution.** The mount job stays; a Duet Builder resolves nothing. | | `Router` / `Routing` | **Delete.** Its job became state-driven child mounting in the shell. | | `Interactor` + `Listener` | **Delete.** The logic left for the reducer; the three shell duties move to the view shell; delegate events are enum cases, so the listener protocol dies with the interactor. | | CombineRIBs `Worker` | **Already gone** by then — converted to Duet workers during the feature's crossing. | | Views and view controllers | **Untouched.** | The measured shape of one finished shell swap: **four files changed in the app module, none in the feature package** — the interactor file renamed to a view shell (34 lines), the builder (28 lines), the renamed spec (18 lines), and one deleted `import CombineRIBs`. By the time a feature reaches the swap, every hard part is behind it. ## What it costs Measured on a production app of 15 RIBs and roughly 17k lines of RIB-layer code, converted end to end — every feature, router retirement included — in **six calendar days** of agent-driven sessions with human review: | Phase | Measured | | - | - | | Foundation — gates, tooling, device pass | ≈2 days | | First feature lane — 3 features, one of them 3.8k LOC | ≈1.5 days | | Remaining tab features — 3 | ≈1.5 days | | Root and spine logic — 5 slices | 1 day | | Router retirement and state persistence | ≈1.5 days | Leaf features measured **under a day each**, and a 3.8k-LOC feature authored directly from its finished spec landed in one session — writing the spec first is the fast order, not the ceremonial one. **What this table does not price.** It was measured on a migration that converted every feature to Swift and reached the Kotlin core afterwards, so it prices the architecture work — the extraction, the tests and fixtures, the router retirement. It does **not** price the second half of each crossing: the Kotlin twin, its Compose shell, and its platform workers. It also does not price the graft, which is one call plus its exit gate. Scale it by your feature count and interactor sizes, then re-price from your own repository once two or three features have crossed. ## What you can check yourself The migration installs its own gates at step 2, and they are ordinary commands in your repository: | Command | What it answers | | - | - | | `tools/duet lint` | Is the manifest well-formed? The fast red, run on every manifest edit. | | `tools/duet record --check` | Did the fixtures change? The port oracle and the CI drift gate. | | `tools/duet verify` | Do both platform lanes pass? The same gate CI runs. | | `tools/duet doctor` | Did anything take a concurrency shortcut the swap tempts? | Progress is readable without running anything: `.modaal/duet-migration-ledger.json` carries a status per surface, and re-running the audit refreshes it. Three safety rails hold throughout: * **The leak detector keeps its authority** while a feature still has its Router, and stays green through every conversion. * **Visuals get snapshotted during shell swaps.** The fixtures pin behavior; snapshots pin pixels. * **Skipped and flaky legacy tests go in a visible registry with an owner** — never deleted quietly, and never made to pass by widening a timing bound. ## How do you know the migration is done? The migration is done when all of these hold: * Zero `import CombineRIBs` anywhere in the tree. * The migration package's dependency edge is deleted from every `Package.swift`. * Every feature in `parity/manifest.yaml` is `kotlin:`-only — no `swift:` line outlives its window. `tools/duet verify` is green and `record --check` shows zero churn. * The tree is shape-equivalent to a fresh **Production app for Android + iOS** scaffold. * The legacy interactor specs are deleted with their interactors — their scenarios were ported 1:1 during conversion, so a surviving spec tests a deleted type. * The repository's own documents are swept: any README or agent-instructions file still describing the RIBs shape is rewritten in the same change. A document describing a shape the repository no longer has is not history — it is instructions to the next reader. * The app's own device pass covers the swapped flows. ## Four things that are never the answer The unit is the feature, not the app. Inside a feature's window the Swift twin lands first with your app's own tests as its oracle, then the Kotlin twin with the fixtures as its oracle. Two changes, two gates. Collapsing them leaves you with no way to tell which half broke. The graft emits them as one atomic step along with the parity workspace and the migration marker that routes every later call. A hand-built plane is missing that marker, the manifest rows and the module wiring. Fixtures are build products. Re-record and read the diff instead: behavioral churn during a port is a finding about the port, and editing the recording deletes the evidence. One dependency edge in at the graft, one dependency edge out at the finish line. It exists so the frozen CombineRIBs fork never enters the Duet framework's own dependency graph, and it has no job once nothing imports CombineRIBs. ## Common questions No. The iOS app builds and runs unchanged from the graft onward, converted and unconverted features coexist, and steps 3 and 4 interleave with ordinary product work. The one thing that changes on day one is that new features are authored in the target shape rather than as new Routers. At step 2, before any of your features are converted — the graft's exit gate is the Android app launching on an emulator with a synthetic feature passing from a recorded fixture. Your features then appear in it one at a time as they cross. Yes, and it is a supported state rather than a broken one. You keep the recorded fixtures and the tests they came from, the determinism work, one DI idiom, and a running Android app carrying the features that crossed. The ledger records exactly where you stopped. It gets a `blocked` or `wont-convert` status in the ledger with a note saying why, and the migration continues around it. Common blockers are found by the audit before they cost anything: a vendor SDK imported directly in an interactor, or timing done with raw `Timer` and `DispatchQueue.asyncAfter` in feature logic. Both are fixed by adding the seam first. Because the two halves have different oracles. The Swift twin lives inside your app and is checked against your existing test suite, which proves the extraction preserved behavior while the feature still ships. The Kotlin twin is checked against the fixtures that extraction produced, which proves the port preserved it. One step, one oracle. Tell the agent your project is going to Android, or that you want to migrate it to Duet. It reads the migration guide, makes the step 0 edits, runs the readiness audit, and comes back with the report and a proposed order before anything is generated. You approve the plan; it does not graft anything first. ## Read next The destination: what Duet is, what the finished tree holds, and how features are authored once you are on it. Where the line between shared logic and native UI falls, and what enforces it — the part your Compose shells are written against. Every template Modaal offers, where CombineRIBs sits among them, and which combination a new project should start from. Building iOS and Android together is part of Pro. The target shape built from an empty tree: what a crossed feature, its shells and its recordings look like with nothing legacy beside them. # Handling Platform-Specific UI on iOS and Android Source: https://docs.modaal.dev/articles/cross-platform-ui-parity Duet's five-layer presentation contract, the kind-to-renderer registry, and the ledger that records iOS/Android divergence, with SwiftUI and Compose examples. This is the technical half of **[Duet](/duet)**. [The overview](/articles/duet) covers what Duet is, which combination of the new-project sentence scaffolds it, and how a project gets built. This one answers the question that follows: **where exactly does the line between shared and platform-specific fall, and what enforces it?** One question comes up in almost every conversation about building the same product on iOS and Android with Modaal: > **"How does Modaal handle platform-specific UI components between the two?"** The short answer is that it doesn't try to unify them. There is **no cross-platform UI layer**: iOS renders SwiftUI, Android renders Compose, and neither knows the other exists. What the two platforms share is *what is on screen*, expressed as a value in feature state; what stays platform-specific is *how that value is rendered*. The line between the two is a written contract, and it's mechanically enforced — the shared half is gated byte-for-byte in CI, while the platform half is deliberately free but written down. **About this article.** [**Duet**](https://github.com/modaal-agent/duet) — the framework behind Modaal's iOS ↔ Android parity — is **generic**: nothing below is specific to one product, one design language, or one category of app. The contract, the registry, and the ledger apply to any dual-platform project. The **worked examples**, though, all come from one real app: **[Memory Lane](https://memorylaneapp.lovable.app/)**, a shared-memories app whose production iOS app was joined by a full Android twin built on Duet. Every number, file name, and deliberate cut quoted here comes from that codebase. Memory Lane is live on the **[App Store](https://apps.apple.com/us/app/memory-lane-private-journal/id6760589051)**; the Play Store listing is coming shortly. ## Where this starts Duet holds three commitments, covered in full on the [Duet overview](/articles/duet): logic is shared and pure (features are reducers over typed state, with effects as data), UI is native and thin (SwiftUI and Compose, with navigation held as *state* that shells render), and parity is measured (both platforms replay the same recorded fixtures, and the gate is byte equality). The third is what makes the first two safe. If you are going to write the UI twice on purpose, you need proof that the *behavior* underneath did not quietly fork. The rest of this article is the machinery that provides it. ## The seam: a five-layer presentation contract Duet ships a normative document — `contracts/presentation-contract.md` — that splits "presentation" into five layers and assigns each one an owner and an enforcement mechanism. | Layer | What it is | Open / closed | Shared or per-platform | | - | - | - | - | | **L0 — structure** | The navigation algebra: `path`, modal `slot`, `tabs`, `stage` | **Closed** — additions are versioned contract events | Shared; **byte-gated by fixtures** | | **L1 — kinds** | What a surface *is* — app enum cases (`memoryDetail`, `sharePicker`, `capture`, …) with pure-value payloads | Open (app-defined) | Shared; lint-checked | | **L2 — verbs** | Actions whose reduction mutates L0. **No silent transitions** | Open (app-defined) | Shared; fixture-gated | | **L3 — manner** | Detents vs `ModalBottomSheet`, covers, animation curves, corner radii, chrome | Open (per-platform) | **Per-platform, free** — ledgered when the manner *class* diverges | | **L4 — islands** | Declared regions whose internals live outside the tree (media viewers, camera sessions) | Open (ledgered escape hatch) | **Per-platform internals**, identical declared boundary | **The rule of thumb:** if a *fixture* can see it, it's shared and gated; if only a *user* can see it, each platform is free to render it in its own way. ### Layer 0 — the structural algebra Four primitives express every navigational shape an app has. They live as ordinary fields in feature state, are serialized canonically, and are the part of state that fixtures pin: | Primitive | Shape (Swift / Kotlin) | Semantics | | - | - | - | | `path` | `var path: [Route]` / `val path: List` | A back-poppable push stack | | `slot` | `var sheet: Sheet?` / `val sheet: Sheet?` | **One** modal slot per node; stacking is composition, not a second slot | | `tabs` | `var activeTab: Tab` / `val activeTab: Tab` | User-switchable; siblings stay alive | | `stage` | `var phase: Phase` / `val phase: Phase` | Non-optional enum; exactly one full-screen child owns the screen; transitions are reducer edges; children destroyed on exit | The set is **closed**: adding a primitive is a versioned contract event with a written review bar, and the default answer is no. A fifth row — `overlay` — is kept in the contract precisely *because* it was measured and **declined** as structure: toasts, hints, error banners and flash clocks are plain reducer fields, several of them legitimately co-exist, and none of them need host lifecycle. Recording the rejection keeps the question from being reopened later. Duet answers this with a measurement rather than an argument. At a defined milestone you sweep **every** reducer's state for navigation-shaped fields and check that the algebra covers them. Run across Memory Lane's full 16-feature conversion, the census came back: `path` ×2, modal `slot` ×5, `tabs` ×1, `stage` ×1 — nothing needed a fifth primitive. One growth had been found earlier in the process (`stage`, for a launch funnel that was neither a stack nor a tab set) and was landed as a numbered amendment with its rejected alternatives written out. The sweep also produced the general boundary rule: **a field is structural only when a generic shell attaches child lifecycle to its transitions.** A plain enum that drives copy and layout inside one view — an onboarding pager's page index, say — is feature data, not L0. No host derives mounts from it, so it isn't structure. ### Layers 1 and 2 — kinds and verbs A **kind** is a case of an app-defined route / sheet / tab enum. The contract doesn't enumerate kinds — that's your product — it only constrains their shape: payloads are **seed values** (ids, configs, flags), never live objects. Adding a presentable surface is one new enum case plus one renderer arm per platform. Nothing else in the contract moves. A **verb** is any action whose reduction mutates an L0 field, and one rule governs all of them: **No silent transitions.** Every L0 mutation happens in a reducer arm in response to a named action. Hosts never write structure directly; they report what the user did. * An interactive sheet dismissal re-enters as an action (`captureFinished(didSave: false)`), guarded against duplicate reports. * A `NavigationStack` pop gesture re-derives as a semantic `.backPressed` — and a cancelled mid-swipe must not send one. * Deep links and push taps enter as `deepLink`-class actions and fold through reducers. The audit is a grep: search the platform diffs for writes to L0 fields outside `store.send`. Any hit is either a violation or a missing verb. This is what makes two hand-written native UIs safe. The iOS host and the Android host can disagree about *everything* visual, because neither one is allowed to make a navigation decision — they can only report that the user did something, and the shared reducer decides what it meant. ### Layer 3 — manner, where platform-specific UI actually lives *Manner* is everything the reducer must not know: detents vs `ModalBottomSheet`, push animation curves, corner radii, drag indicators, chrome. It is per-platform and unconstrained. A `detent` field appearing in feature state is a contract violation. [Tutorial 5](/tutorials/duet-05-navigation-as-state#mount-the-flow-in-each-platforms-idiom) shows the layer on one screen: the same upgrade flow, mounted from one state value, presented as a sheet on iOS and a modal bottom sheet on Android with the recordings unchanged. ### Layer 4 — opaque islands Some regions can't be modeled as state and shouldn't be: a pinch-zoom media pager, a video transport, a camera session. Duet calls these **islands** and makes them first-class — but **ledgered**. An island declares the *boundary summary* (the serializable value persisted instead of its internals), owns no tree children, and restores to its summary rather than its internal gesture state. ## The mechanism: the presentation registry Both flavors of the framework ship a per-host, **kind → renderer** table. A feature ships the kind; each platform's **composition root** binds how that kind renders. Manner lives entirely inside the renderer closure, so reducers and fixtures never see it. ```swift Swift (SwiftUI) theme={null} @MainActor public final class PresentationRegistry { public func register( _ kind: Kind.Type, renderer: @escaping (Kind) -> AnyView? ) public func view(for kind: Kind) -> AnyView? } ``` ```kotlin Kotlin (Compose) theme={null} class PresentationRegistry { fun register(kind: KClass, renderer: (Kind) -> Surface?) fun surfaceFor(kind: Any): Surface? } ``` The two APIs aren't a mechanical transliteration of each other, and the difference is deliberate. SwiftUI forces a type-erasure box, so the Swift side pins `AnyView`. Kotlin needs none, so the Kotlin side stays generic over `Surface` — which keeps the published artifact **Compose-free**, and a Compose app simply instantiates `PresentationRegistry<@Composable () -> Unit>` from its composition roots. ### The same five kinds, two renderings Here is Memory Lane's main tab host — the *identical* set of kinds, resolved by each platform's composition root: ```swift MainNavShell.swift (iOS) theme={null} presentation.register(MainSheet.self) { [weak self] sheet in switch (sheet, self?.sheetChild) { case let (.capture, .capture(token, _)): return AnyView( HostedViewController(controller: token.viewController) .ignoresSafeArea() .presentationDetents([.large]) .presentationDragIndicator(.visible) .presentationCornerRadius(28)) case let (.notificationPriming, .priming(child)): return AnyView( HostedViewController(controller: child.viewController) .presentationDetents([.medium])) // … } } presentation.register(TimelineSheet.self) { [weak self] sheet in AnyView(SharePickerHostView(store: child.store) .presentationDetents([.medium, .large])) } ``` ```kotlin MainNavHost.kt (Android) theme={null} registry.register(MainSheet::class) { sheet -> when (sheet) { MainSheet.Capture -> {{ val child by shell.sheetChild.collectAsState() (child as? SheetChild.Capture)?.let { capture -> ModalBottomSheet(onDismissRequest = { shell.navStore.send(MainNavAction.CaptureFinished(didSave = false)) }) { CaptureProductSheet(capture) } } }} // … } } registry.register(TimelineSheet::class) { _ -> { ModalBottomSheet(onDismissRequest = { shell.timelineStore.send(TimelineAction.ShareSheetDismissed) }) { SharePickerSheet(child) } } } ``` `HostedViewController` + detents + a 28pt corner radius on one side; `ModalBottomSheet` on the other. The Android host additionally installs `BackHandler`s in a documented composition order — node before child, tab-gated, root modal last — a construct with no iOS analogue whatsoever. None of that reaches the shared half. ## Worked example: one slot, ten kinds, two very different platforms The clearest illustration in Memory Lane is the capture surface. Its shared state carries **one** modal slot with **ten** kinds — photo source, photo library, camera, video source, video recorder, video library, voice recorder, location, friends, date. One optional enum, payload-free. Now look at what each platform does with it: | | iOS | Android | | - | - | - | | Source pickers (`photoSource`, `videoSource`) | `.confirmationDialog` | Material `AlertDialog` | | Library / camera (`photoLibrary`, `camera`, `videoLibrary`, `videoRecorder`) | `.sheet(item:)` / `.fullScreenCover(item:)` wrapping in-app AVFoundation surfaces | System **Activity-result contracts** — Photo Picker, `TakePicture`, `CaptureVideo` (chrome owned by the system apps) | | Voice recorder | `.fullScreenCover` over an `AVAudioRecorder` surface | `ModalBottomSheet` over `MediaRecorder` | | Date | Sheet | `DatePickerDialog` | One side uses three different SwiftUI presentation channels; the other uses a mix of Material dialogs and *other applications entirely*. And yet the reducer, the state shape, and the recorded fixtures are byte-identical — because on both platforms **every** interactive dismissal re-enters through the same single verb, `modalDismissed(kind)`, whose kind-equality guard makes channel races inert. The legacy iOS code this replaced carried an `activeSheet` **and** an `activeFullScreen` view-state pair — two booleans-in-disguise that could both be set. Collapsing them into one slot is what made the Android twin expressible at all: N mutually-exclusive booleans cannot encode exclusivity, so two platforms will eventually disagree about which one wins. ## Divergence is ledgered, not forbidden Duet doesn't prevent the two platforms from differing when they genuinely need to. It requires the difference to be a **decision on record** rather than a discovery six months later. There are two escape hatches, both declared in the project's `parity/manifest.yaml`: Filed when one platform deliberately renders a kind in a different manner **class** than its twin — a full-screen dialog where the other uses a half-height sheet. Not for styling: different corner radii, different animation curves, different type scales need nothing. ```yaml theme={null} waivers: - kind: .. feature: platform: ios | android manner: reason: since: ``` Filed for regions whose internal presentation state lives outside the tree. The **kinds and boundaries are identical** across platforms; only the opaque internals differ. ```yaml theme={null} islands: - id: . feature: boundary: reason: since: ``` Memory Lane's ledger carries three islands with an entry per platform: the photo carousel (UIKit pager ↔ Compose pager `Dialog`), the video player (`AVPlayer` transport ↔ `VideoView`/`MediaController`), and the capture chrome (`AVCapture`/`AVAudioRecorder` sessions ↔ system capture apps + `MediaRecorder`). Same kind, same boundary summary, wholly different internals. A lint (`lockstep-lint`) validates **shape and cross-references** — every entry names a real feature, a valid platform, an ISO date — and deliberately does **not** judge whether the divergence is justified. That judgment is left to review: the lint guarantees the ledger is complete and parseable, and a human decides whether each entry is warranted. Memory Lane's standing ledger, after the full Android build-out: **0 waivers, 6 island entries** (3 regions × 2 platforms). **The third-verb heuristic.** Islands can quietly grow into structure. The rule: the first two actions an island wants are usually incidental; the **third** means it actually has structure — promote it to a kind. Failure/revert twins of an existing verb don't count (they're that verb's plumbing). The sweep re-runs at every milestone that added manner surfaces. ## What's gated versus what's free | Layer | Cross-platform obligation | Enforcement | | - | - | - | | **L0** | Identical fields, identical serialization | Fixtures — `duet verify`, `duet record --check` | | **L1** | Identical kinds and payloads per feature | Lockstep-lint declaration checks | | **L2** | Identical verbs, identical reductions | Fixtures; per-milestone ledger audit | | **L3** | **Free per platform**; divergent manner *classes* waived in the ledger | Lint shape check; human review | | **L4** | Islands declared identically; internals free; boundary summaries identical | Lint shape check; restore drill; periodic sweep | ## What this buys you When Memory Lane's sixteen Android product views were written — feeds, detail, capture, profile, the whole launch funnel — the measured results were: Not one feature module changed. `duet verify` held at **134/134** by construction, and the fixture corpus was never re-recorded. Every platform-specific decision below cost at most a line in a log — no contract change, no waiver, no re-record. The deliberate cuts, in full: * **No Lottie splash art on Android** — the phase is reducer-clocked either way; the art is manner. * **No Apple sign-in button** — the platform has no Apple sign-in, so the environment maps that path to a failure the shared reducer already handles. * **No reaction-flight animation overlay** — pure chrome; it writes no verbs. * **No video transcode pass on Android** — the system camera emits MP4 already, so the iOS AVFoundation normalize step is simply platform manner, and the Android arm is a staged-file adoption. * **Compose pager `Dialog` instead of the UIKit carousel** — declared island internals. * **Text-only tab labels shipped first**, icons in a later polish pass — a manner detail that never blocked a behavior gate. And on theme: the **mood** is kept in step, not the token system. Android runs a Material 3 scheme tuned to the iOS palette — warm paper, bark primary, light and dark — rather than an attempt to port design tokens across two design languages that disagree about what a token is. ## Adding a presentable surface: the whole recipe A new case on the owning node's route/sheet enum, with a pure-value payload. No rendering exists yet. The golden-first rule: pin the scenario byte-for-byte on both platforms **before** any UI is written. This is the step that makes the rest safe. Reducer arms that set and clear the kind, named for intent. Interactive dismissals re-enter as the view's *report* action with a stale-dismissal guard. `registry.register(Kind.self) { payload in … }`. Manner lives **only** here — detents, covers, animation, chrome. Renderers may wrap any native API, including tricks and workarounds; the registry is where platform-specific creativity goes without touching the contract. Different manner *class* between platforms → a waiver entry. Different styling → nothing at all. Two things must never happen: a reducer learning manner (a `detent` field in feature state), and a host writing an L0 field outside `store.send`. Both violate the contract, and the per-milestone audit greps for exactly these two patterns. ## Common questions Yes — the *views*, but not the logic, the navigation decisions, the effect handling, or the test corpus. With any native-UI approach you were writing those views twice anyway; what you stop paying twice for is behavior, and what you gain is proof that the two behaviors agree. The measured shape of that trade in Memory Lane: sixteen Android product surfaces built against stores that already had a passing fixture corpus, with zero logic changes and no re-recording. Then it doesn't, and the ledger records why. Apple sign-in has no Android counterpart — the Android environment maps that path to the failure arm the shared reducer already models, and the button doesn't exist. No contract change is needed; the shared half never assumed the button. Nothing in the contract forbids it — manner is free, so what a renderer closure returns is entirely your business. But Duet's toolchain gives you no help there, and its whole premise is that native look-and-feel is worth writing twice while behavior isn't. Sharing views would be working against the grain of the framework. If the drift is in L0–L2, CI fails: both platforms replay the same recorded fixtures and the gate is byte equality, so a diverging reducer is a red build on the commit that caused it. If the difference is in L3–L4, it isn't drift — it's manner: either it needed a ledger entry (which the lint's shape check enforces) or it needed nothing at all. *** ## What does Duet share, and what does it let diverge? Modaal shares the navigation *structure* as data and gates it in CI. The widgets are fully native on each side and are expected to differ; where they differ in kind rather than in pixels, the difference is recorded in a ledger that the lint checks. **Status.** Duet is **pre-release**. The framework, both flavors, and the toolchain have landed, and the contracts are versioned alongside the code — but no artifacts are published yet, so treat the API surface as a preview until the first tagged release. The contracts quoted here (`presentation-contract.md`, `store-kernel-contract.md`, `serialization.md`, `replay-protocol-v1.md`) ship in the open framework repository. The product page is [modaal.dev/duet](https://modaal.dev/duet). What Duet is, the two combinations that scaffold it, what lands on disk, and how to add Android to an iPhone-only project. The per-feature route from an existing Production app project to a shared Kotlin core and an Android app. The tree behind the examples on this page: feature modules, builders, the navigation hosts and the fixtures directory — in the Memory Lane codebase. Every term this page leans on — kind, shell, reducer, fixture, the ledger — defined with one link each. The contract applied by hand: one sheet value in two idioms in Tutorial 5, tokens in Tutorial 7, and strings kept out of state in Tutorial 8. # The Duet Framework: One Shared Core, Two Native Apps Source: https://docs.modaal.dev/articles/duet The Duet framework: feature logic written once, native SwiftUI and Jetpack Compose interfaces, and a CI gate that fails when the two apps behave differently. **Duet** is the architecture Modaal scaffolds for a **Production app** — the kind you pick in the sentence on the home screen, for **Android + iOS** or for **iOS**. Each feature's logic — its state, the actions that change it, the effects it requests — is written **once**. The iPhone app renders that state in SwiftUI; the Android app renders it in Jetpack Compose. Both apps replay the same recorded fixture files, and the build fails when the two replays differ. There is no shared UI toolkit and no web view. Both apps are ordinary native codebases you can open in Xcode and Android Studio, ship to the App Store and Google Play, and export at any time. **Status.** Android support is available to every Modaal account, in open beta. Modaal Free builds one platform of your choice; building iOS and Android together is part of [Pro](/credits-and-plans). The product page is [modaal.dev/duet](https://modaal.dev/duet). ## What you get One pure reducer per feature holds the behavior. Adding a rule, fixing a bug, or changing a flow is one edit that both apps pick up, rather than the same change applied twice in two languages. SwiftUI on iPhone and iPad, Jetpack Compose on Android. Each app follows its own platform's conventions — Apple's Human Interface Guidelines on one side, Material 3 on the other. Behavior is recorded as fixture files. Both platforms replay the same fixtures and the gate is byte equality, so a divergence fails the build on the commit that caused it. Start iPhone-only and add Android per feature later. The fixtures recorded from the Swift reducer are what the Kotlin one is verified against. ## How Duet compares to other cross-platform approaches
| | **Flutter, React Native** | **Compose Multiplatform** | **Two separate codebases** | **Duet** | | - | - | - | - | - | | **Which layer draws the screen** | One toolkit renders on both platforms; the widget set belongs to the framework | Compose renders on both platforms | Each platform's native toolkit | Each platform's native toolkit — SwiftUI and Compose | | **Where feature logic lives** | Once, in Dart or JavaScript | Once, in Kotlin | Twice, once per platform | Once, in a pure reducer per feature | | **What proves the two platforms agree** | Nothing separate — there is one implementation | Nothing separate — there is one implementation | Manual QA on both apps | Recorded fixtures replayed on both platforms; CI compares byte-for-byte | | **A native, perf-heavy island** | An escape through the framework's bridge — platform view, texture or native component, with SwiftUI and Compose reachable only as wrapped views inside it — written per platform in Swift/Objective-C(++) and Kotlin, compiled by Xcode and Gradle in the app build, with its logic outside the shared code | Shared when the framework can draw it; on iOS a native island is a `UIKitView` built in Kotlin through Objective-C interop, or in Swift for Swift-only APIs and SwiftUI views, inside a framework-drawn screen | Ordinary platform code, written twice and compiled by Xcode and Gradle | Ordinary platform code, written twice and compiled by Xcode and Gradle; the reducer decides, a worker runs the pipeline, and the seam is recorded |
Each column is argued in full on its own page: [the framework survey](/articles/best-cross-platform-framework) places every mainstream option on these axes, and the pairwise pages cover [Flutter](/articles/duet-vs-flutter), [React Native](/articles/duet-vs-react-native), [Compose Multiplatform](/articles/duet-vs-compose-multiplatform) and [Ionic Capacitor](/articles/duet-vs-ionic-capacitor). The trade Duet makes is explicit: **you write the views twice and the behavior once.** Screens, navigation chrome, animations and controls are authored per platform, which is what keeps each app native. Everything a fixture can see — state shape, the actions that change it, the effects a reducer requests, the order they come back in — is shared and gated. The agent writes both view layers. The practical cost of "twice" is agent time on the second set of screens; the behavior underneath is already written, recorded and passing, so there is no second design pass. ## Starting a Duet project ### 1. Say you're building a production app The Modaal home screen opens with one sentence above the composer: *I'm building a `kind` for `platform`*. **Production app** is the kind that creates a Duet project, and the platform word picks the flavor: The platform menu open under the second word of the Modaal new-project sentence: iOS (iPhone now, Android later), Android + iOS (One codebase, both stores, checked), and a checkbox row Add Apple Watch companion | Platform word | The line under it | What it creates | Flavor | | - | - | - | - | | **iOS** | iPhone now, Android later | One iPhone app | Duet, Swift | | **Android + iOS** | One codebase, both stores | An iPhone app **and** an Android app | Duet, Kotlin Multiplatform | The sentence opens on **Production app for iOS** the first time, and afterwards on the combination you last created a project with. **Android + iOS** is part of Pro: on a plan without it the row still selects and carries a **Pro** chip, and **Start new project** opens the upgrade screen. The **+ Apple Watch** chip beside the platform word adds a watchOS companion target to either flavor. [Starting a new project](/articles/new-project#the-new-project-flow) covers every combination the sentence offers, Duet and otherwise. ### 2. Describe what you're building Type your idea into the composer and press **Start new project**. You can attach a PRD, screenshots, a design doc, or a Figma link. Nothing is written to disk until you press the button — no project, no folder, no git repository. There is no second step either: the app's name, its identifiers and its device families are all derived from your description. ### 3. Modaal creates the project A folder, a project record and a git repository, all under the name read from your description. The app targets, the shared core, the parity tooling, the theming and control libraries, a CI workflow, and a test suite that passes on the first run. Any files you attached are copied into the project. The agent receives it with Plan mode on and continues from there. You wait on the home screen while that runs: the composer goes read-only, the send button reads **Setting up…**, and a status line names the step. **Cancel** is there until scaffolding starts. ### 4. Check the bundle identifier before you ship The bundle identifier is derived as `.` — the organization identifier being the reverse-DNS prefix your last project used, or one suggested from your account. It is what App Store Connect, TestFlight and Firebase register. **Once a build reaches TestFlight or the App Store, its Bundle ID can never be changed.** Set it before your first upload, in the **Project Settings** tab of the sidebar. [How the identifiers fit together](/articles/new-project#how-the-three-fields-fit-together) explains what each one is for. ### 5. The first turn: PRD and first feature spec The sentence has already fixed the structure, so the agent's first turn starts at product scope. It reads your description and attachments, writes a structured **`PRD.md`** at the project root, then plans a workable first iteration into `specs/001-/spec.md`. It never asks which architecture to use — the template and the per-target architecture are recorded in `.modaal/project.json` and read on every turn. When the plan looks right, turn **Plan** off and say *"let's build this"*. See [Modes and prompts](/articles/modes-and-prompts) for how the Plan toggle works from there. *** ## The two Duet templates Both platform words scaffold the same architecture, the same authoring loop and the same gates. They differ in how many app targets exist on day one, and in which language holds the feature logic. ### Production app for Android + iOS: the Kotlin Multiplatform flavor **Template id:** `duet-kmp` · **Emits:** an iPhone app and an Android app · **Feature logic:** Kotlin `commonMain` Feature logic is one Kotlin module per feature under `src-kmp/`. The Android app consumes it directly. The iPhone app consumes the **same compiled logic** through a Kotlin/Native framework — there is no second implementation of a reducer to keep in step, and no hand-written Swift copy of any Kotlin rule. The iPhone app links the Kotlin/Native runtime, including its garbage collector, and Swift reaches the core through SKIE's generated Swift layer; [Kotlin/Native GC and Swift ARC](/articles/kotlin-native-gc-swift-arc) explains who frees what. ```text theme={null} src-kmp/ # the shared Kotlin core ├── / # one module per feature: State, Action, Effect, reducer ├── services/ # cross-cutting ports, log vocabulary, consent state ├── telemetry/ # the closed analytics event grammar ├── theming/ # design tokens, shared by both apps ├── apple-umbrella/ # one Kotlin/Native framework aggregating every feature ├── replay-runner/ # replays fixtures on the JVM └── app/ # the Android app: Compose shell, workers, Activity edge src-ios/ ├── App/ # the iPhone app target; xcodegen.yml is the source └── Libraries/ # Kit (links the framework), Main (composition root), # Theming, ThemedControls parity/ ├── manifest.yaml # the declared features, fixtures and generators ├── authoring.md # this repo's own front door for feature work ├── feature-specs/ # one one-pager per feature └── fixtures/ # recorded behavior — build products, never hand-edited tools/duet # the CLI that records and verifies .github/workflows/ # the parity gates, running on every push ``` **Pick it when** Android is in scope now, when you want both stores at launch, or when you would rather take on the Kotlin core now than migrate to it later. **What it needs:** the Android SDK and a JDK on your Mac, alongside Xcode. Modaal checks for both and shows an **Action needed: install the Android SDK** banner with a button that installs what is missing — [Set up the Android toolchain](/guides/setup-android) documents the whole flow. ### Production app for iOS: the Swift flavor **Template id:** `duet-swift-ios` · **Emits:** one iPhone app · **Feature logic:** Swift packages Feature logic is one pair of Swift packages per feature under `src-ios/Subtrees//` — the gated half holding the reducer, its types and its scenario tests, and the app-facing half holding the builder and the SwiftUI shell. Pure SwiftPM: no Gradle, no Kotlin, no Android SDK needed to build or ship it. ```text theme={null} src-ios/ ├── Subtrees// # one directory per feature: │ ├── Feature/ # the reducer, its types, the worker, the scenarios │ └── Node/ # the builder and the SwiftUI shell ├── App/ # the iPhone app target └── Libraries/ # Main (composition root), Theming, ThemedControls parity/ ├── manifest.yaml ├── authoring.md ├── feature-specs/ # the porting source of truth └── fixtures/ # recorded behavior — build products, never hand-edited tools/duet .github/workflows/ ``` This flavor keeps the one practice an iOS-only project would normally skip: **every feature is recorded and specced exactly as in the two-platform flavor.** The fixture corpus is the portable spec of each feature's behavior, and `parity/feature-specs/.md` is the porting source of truth. Both are in place from the first feature, before there is an Android app to port to. **Pick it when** Android is a "probably, later", when you want the smallest tree that still converges, or when you want the iPhone app in users' hands before taking on a second toolchain. **What it needs:** Xcode only. ### Choosing between them | Your situation | Sentence | | - | - | | Android is in scope now, or you want both stores at launch | **Production app** for **Android + iOS** | | Android is a "probably, later" and the iPhone app ships first | **Production app** for **iOS** | | You want one toolchain on your machine for now | **Production app** for **iOS** | | You are certain the product is iPhone-only forever | Either platform word works; **iOS** is the smaller tree | | You are building a game or a drawn playfield | **2D game** — no Duet template scaffolds a SpriteKit scene host | | You are validating a throwaway idea | **Quick prototype** — note that an MVVM project has no migration path to Duet | **Pick Production app at creation time if Android is anywhere in the plan.** The architecture a template fixes holds for the life of the project. A CombineRIBs project can be carried to Duet feature by feature, but an MVVM one cannot — going to production from **Quick prototype** means a second project, written again. *** ## Adding Android to an iPhone-only project The route available today moves the Swift flavor onto the Kotlin Multiplatform flavor. Android arrives as a **per-feature, fixture-gated migration** into the same repository, not a second tree kept in step by hand. The Gradle plane, the shared-core modules, the Android app shell and the Kotlin side of the parity workspace land in the existing tree. The iPhone app builds, runs and ships unchanged while they sit alongside it. Per feature: the Kotlin reducer is written against the fixtures **already recorded** from the Swift one, the Android app mounts the feature, and the iPhone app moves onto the shared core for it. The gate is that the same fixture bytes replay on the new side. `parity/manifest.yaml` declares the ported side per feature, so a half-migrated repository is an ordinary, shippable state. You can stop between any two features and keep the work finished so far. This is the same shape as the [CombineRIBs → Duet migration](/articles/combineribs-to-duet) an existing **Production app** project takes, with several of its steps already done: | Migration step | From CombineRIBs | From the Duet Swift flavor | | - | - | - | | Readiness audit — find the untested logic and the shapes that resist conversion | Required first | Not needed; every feature is already a pure reducer | | "Author new features in the target shape from today" | A rule to adopt, plus a sweep of the repo's agent documents | Already how the repo works | | Per feature: write the reducer, its spec and its fixtures | The bulk of the work — and it starts by discovering what the old code actually did | Already done, and recorded | | Per feature: write the Kotlin twin | Against fixtures recorded during the same migration | Against fixtures recorded when the feature was first built | | Retire the old architecture — coexistence packages, shell swap | A terminal phase of its own | Nothing to retire | What is left is adding the Kotlin core to the repository and translating one feature at a time: the Kotlin reducer has an executable specification to satisfy before its first line is written, which bounds the work. You do not schedule this from a menu. Ask the agent for Android when you want it, and it works through the steps above with you, one feature at a time. The route above moves the iPhone app onto the shared core. A second route, in development, keeps the iPhone app's Swift core and adds a Kotlin core for the Android app, with both checked against the same recordings: [Duet Twins](/articles/duet-twins). *** ## How a feature gets built This is the loop the agent runs for every behavior change, in either flavor, and the one you run yourself if you work in the repo directly: A one-pager under `parity/feature-specs/.md`: the states, the transitions, and a row per ending the feature can reach. `Given` / `When` / `Then` — a branch per ending. This is the executable form of the spec. `tools/duet record --feature ` compiles the scenario into fixture files. Fixtures are build products: they are never edited by hand, and where a fixture and the prose disagree, the fixture is authoritative. Adjust the pure reducer until the scenario passes. The reducer takes no environment — no clocks, no IDs, no network — so anything nondeterministic round-trips through an effect and comes back as an action. That is what makes the recording replay identically on both platforms. A SwiftUI screen on iOS, a Compose screen on Android. Each one reads state and sends actions; neither makes a navigation decision of its own. `tools/duet verify` — the same gate CI runs. It replays every fixture on every platform and compares byte-for-byte. You do not have to run these commands: the agent runs them, and the CI workflow in the scaffold runs them again on every push. They are in your repository and documented in `parity/authoring.md`, so you can run them yourself at any time. To run this loop by hand from an empty tree to the CI gate, follow the [Duet tutorials](/tutorials/duet): nine tutorials build one app, Foyer, on both platforms, each opening a starting tree and ending with a finished tree you can compare against, all in the public [duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials). [Tutorial 1](/tutorials/duet-01-first-feature) runs the spec, scenario, record, reducer and verify steps on one feature with no UI; [Tutorial 2](/tutorials/duet-02-two-apps) wires the first shells; [Tutorial 6](/tutorials/duet-06-checks-in-ci) puts verify and the checks around it into a CI workflow. ## What Duet requires of you Stated plainly, so nothing is a surprise later: * **Views are written twice.** Two screen implementations per feature, one per toolkit. The logic, the navigation decisions, the effect handling and the test corpus are written once. * **The architecture is fixed at creation.** Duet projects stay Duet projects. Modaal has no command that converts a target from one architecture to another; the migrations that exist run *into* Duet, feature by feature. * **No game template.** No Duet template scaffolds a SpriteKit scene host. A game starts from **2D game**, which is CombineRIBs. * **Building both platforms is a Pro feature.** Free builds one platform of your choice. See [Plans & pricing](/credits-and-plans). * **You still need a Mac.** Modaal builds and runs through Xcode, so macOS with Xcode 26 or newer is required for the iOS half. See [Set up Xcode](/guides/setup-xcode). * **Duet is pre-release.** The framework, both flavors and the toolchain have landed and the contracts are versioned alongside the code, but no artifacts are published yet. Treat the API surface as a preview. ## Common questions **Production app for Android + iOS** is built on Kotlin Multiplatform: the shared core is Kotlin `commonMain`, and the iPhone app consumes it through a Kotlin/Native framework. What Duet adds on top is the part KMP leaves to you — the feature shape, the recorded fixtures, and the CI gate that compares the two platforms' behavior. **Production app for iOS** is pure Swift with no Kotlin or Gradle in the tree. No. There is no cross-platform UI layer. iOS renders SwiftUI, Android renders Jetpack Compose, and there is no shared UI code between them. What is shared is *what is on screen*, expressed as a value in feature state; what stays per platform is *how that value is rendered*. [Handling platform-specific UI](/articles/cross-platform-ui-parity) is the full treatment of where that line sits, and [Tutorial 5](/tutorials/duet-05-navigation-as-state#mount-the-flow-in-each-platforms-idiom) shows it on one screen: the same upgrade flow presented as a sheet on iOS and a bottom sheet on Android from one state value. Each app keeps its own platform lifecycle, and no lifecycle callback makes a decision. Three rules keep lifecycle handling out of the shared logic: * **Stores live outside the view tree.** On Android the app's single Activity keeps the root of the store tree in a retained object, `RetainedRoot`, held by Essenty's `InstanceKeeper`. A rotation recreates the Activity and the composition, and `getOrCreate` returns the same root with every store still running. Finishing the Activity is the only teardown. On iOS the scene delegate holds the root and tears it down in `sceneDidDisconnect`. * **Views report what happened, and reducers decide.** A composable or a SwiftUI view sends actions such as `Appeared`. Compose re-runs its effects after a rotation, and SwiftUI calls `onAppear` again when a tab is shown again, so a reducer that receives `Appeared` ignores a repeat. That guard is part of the recorded behavior both platforms replay. * **Navigation is state, so restoring it means restoring data.** Before the process can be killed, each app saves the route spine: every level's route value, encoded by one Kotlin function. Android writes it in `onSaveInstanceState` and iOS in the scene's `stateRestorationActivity`. On relaunch the tree is rebuilt with the spine as each store's initial state. Back presses, sheet dismissals and deep links also enter the shared logic as actions. ```kotlin MainActivity.kt (simplified) theme={null} class MainActivity : ComponentActivity() { private lateinit var retained: RetainedRoot override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Null on a fresh start and after a rotation; the saved spine after process death. val restored = decodeRouteSpine(savedInstanceState?.getString(SPINE_KEY)) // A rotation returns the same instance, with every store still running. retained = instanceKeeper().getOrCreate { RetainedRoot(Dispatchers.Main.immediate, RootMount::teardown) { scope -> RootBuilder(dependency).buildRoot(scope, restored) } } setContent { AppRoot(retained.component) } } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putString(SPINE_KEY, encodeRouteSpine(retained.component.routeSpine())) } } ``` ```swift SceneDelegate.swift (simplified) theme={null} func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { let saved = session.stateRestorationActivity?.userInfo?[spineKey] as? String let root = RootBuilder(dependency: SceneComponent()).buildRoot(restored: decodeRouteSpine(text: saved)) window.rootViewController = UIHostingController(rootView: RootView(shell: root.shell)) window.makeKeyAndVisible() root.shell.activate() // mounts the first child once the window is visible self.root = root } func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? { let activity = NSUserActivity(activityType: spineActivityType) // The same Kotlin encoder the Android app calls. activity.addUserInfoEntries(from: [spineKey: encodeRouteSpine(spine: root.shell.routeSpine())]) return activity } func sceneDidDisconnect(_ scene: UIScene) { root?.shell.deactivate() // dropping the reference alone cancels nothing on the Kotlin side root = nil } ``` ```kotlin SplashFeature.kt (commonMain, simplified) theme={null} SplashAction.Appeared -> if (state.isArmed) Reduced(state) // a repeated Appeared writes nothing and emits nothing else Reduced(state.copy(isArmed = true), listOf(Effect.Run(ArmSafetyNet(SAFETY_NET_MILLIS)))) ``` The recordings check the reducers, including these guards. The recordings do not check the Activity, the scene delegate or the views; host tests do. `RootFlowTest` on the JVM and `RootCompositionSpec` in Swift each build the tree, tear it down, and rebuild it from a saved spine. [Tutorial 2](/tutorials/duet-02-two-apps#build-the-compose-app) writes the retained root, [Tutorial 5](/tutorials/duet-05-navigation-as-state#gather-the-route-spine-and-restore-from-it) adds the route spine and [back handling on Android](/tutorials/duet-05-navigation-as-state#make-back-an-action-on-android), and [Inside a Duet Android app](/articles/duet-android-app-anatomy#what-is-in-the-android-app-module) walks through the Activity of a shipped app. No. Every feature has its own store with its own state. A parent reducer knows two things about a child: which child is mounted, stored as a field in the parent's own state, and the child's delegate event type, wrapped in one of the parent's actions. It never holds a child's state and never sends a child's actions. ```kotlin RootFeature.kt and SignInFeature.kt (commonMain, simplified) theme={null} data class RootState( val phase: RootPhase = RootPhase.Splash, // which child is mounted val entitlement: Entitlement = Entitlement.Free, // the root's own data ) // no SignInState, no MainState sealed interface RootAction { data class SignIn(val event: SignInDelegateEvent) : RootAction // the child's outward events only data class Main(val event: MainDelegateEvent) : RootAction data class EntitlementChanged(val entitlement: Entitlement) : RootAction } // In the child: the event is returned as effect data, so the child's recording contains it. is SignInOutcome.SignedIn -> Reduced(state.copy(isSigningIn = false), listOf(Effect.Run(NotifyHost(Completed(displayName))))) ``` The stores are connected in the app module, by one Builder per level. A `ChildSlot` builds the child the parent's state names and tears down the previous one, and composables render whichever child is mounted: ```kotlin RootBuilder.kt, MainBuilder.kt and AppRoot.kt (Android app module, simplified) theme={null} val slot = host.adopt(ChildSlot( build = { phase -> when (phase) { RootPhase.SignIn -> RootChildMount.SignIn( SignInBuilder(component).buildSignIn(onDelegate = { rootStore.send(RootAction.SignIn(it)) })) RootPhase.Main -> RootChildMount.Main( MainBuilder(component).buildMain(displayName = rootStore.state.value.auth.displayNameOrGuest, entitlement = mount.entitlement)) // … } }, teardown = { it.teardown() }, )) host.adopt(StateTransitions(scope, rootStore.state) { _, state -> mount.project(state.entitlement) // publish the one value the tabs read slot.reconcile(state.phase) // build the named child, tear down the previous one mount.publish(slot.activeHandle) // the child the composables render }) // MainBuilder: the value reaches the tab as the tab's own action. host.adopt(StateTransitions(scope, entitlement) { _, value -> homeStore.send(HomeAction.EntitlementChanged(value)) }) // AppRoot: collects the mounted child and renders its screen over the child's own store. @Composable fun AppRoot(root: RootMount) { when (val child = root.child.collectAsState().value) { is RootChildMount.SignIn -> SignInScreen(child.store) is RootChildMount.Main -> MainScreen(child.mount) // … } } ``` Values move between levels in four ways: * **Up:** a child's [delegate event](/articles/duet-glossary#delegate), received by the parent as one of its actions. * **Down, once:** a seed value passed to the child's Builder when the child is mounted, such as the display name above. * **Down, while mounted:** a [slice](/articles/duet-glossary#slice). The parent publishes one value from its state, and the child receives each change as an action of its own. The child reads the value and never writes it back. * **Between siblings:** [lateral state](/articles/duet-glossary#lateral-state), a worker owned by the siblings' nearest common parent. On Android the Activity holds only the root of this tree. Unlike a per-screen ViewModel, which is scoped to a back-stack entry, a child store exists while its parent's state names it. [Tutorial 3](/tutorials/duet-03-composing-features#compose-the-tree-on-android) builds the tree on both platforms, and [Tutorial 4](/tutorials/duet-04-workers#project-the-entitlement-down-as-a-slice) adds the slice. Yes. **Production app for iOS** emits a complete iPhone app — an XcodeGen-generated Xcode project, build configurations, an app icon, a localization catalog and a passing test suite. It is a shipping iOS app that also keeps a portable record of its own behavior. The shared reducer models the unavailable path as a failure, and the project's `parity/manifest.yaml` records the divergence. Apple sign-in has no Android counterpart: the Android environment maps that path to a failure the reducer already handles, and the Android UI has no Apple sign-in button. The shared half needs no contract change, because it never assumed the button was there. Yes. The output is native codebases in one git repository — an XcodeGen-generated Xcode project on one side, a Gradle project on the other. Open them, edit them, run them, ship them. Modaal adds no proprietary format and no export step. A CombineRIBs project has a documented migration path into the Duet shape, taken feature by feature — [Migrating a CombineRIBs app to Duet](/articles/combineribs-to-duet) is the walkthrough. A **Quick prototype** (MVVM) project has no migration path — Android there means a new project. The reducer decides; workers do the work. Network calls, disk access, camera sessions and timers live in *workers* behind interfaces the reducer names as effects, and each platform supplies its own implementation. The shared half is the decisions and the ordering; the platform half is the I/O. [Tutorial 4](/tutorials/duet-04-workers#write-the-workers-on-android) writes the first two workers and the [on-device backend](/tutorials/duet-04-workers#write-the-on-device-backend) they observe. ## Read next Nine hands-on tutorials build one native iOS and Android app, Foyer, from a first Kotlin feature to the CI checks, theming, localization and analytics. The technical deep dive: the five-layer presentation contract, the kind→renderer registry, and how deliberate iOS/Android divergence is recorded. The full walkthrough of the new-project sentence and every template Modaal offers, Duet and otherwise. Already on a CombineRIBs project? The per-feature route to a shared Kotlin core and an Android app. The decision upstream of Duet: write twice, share everything, or share the logic — with costs and returns. Flutter, React Native, Compose Multiplatform and the rest on the same three axes as the table above. The emitted Android tree file by file: feature modules, the thin app module, workers and fixtures. Reducer, worker, scenario, fixture, the checks — every term defined with one link each. The open framework repository — both flavors, the toolchain, and the versioned contracts. The same shared-core-versus-shipped-runtime question argued for a product audience, on modaal.dev. # The Structure of a Kotlin Multiplatform Android App Source: https://docs.modaal.dev/articles/duet-android-app-anatomy A Duet Android app is a thin Compose shell over a shared Kotlin core: one Gradle module per feature, plus recorded fixtures its iOS twin replays too. A Duet-built Android app is structured as a thin, native Compose app over a shared Kotlin core: every feature's logic lives in its own Gradle module under `commonMain`, the Android app module holds only what is platform work by nature — the entry activity, the screens, the I/O workers — and recorded fixtures pin what the logic does. **Duet**, Modaal's cross-platform parity framework for native iOS and Android apps, scaffolds this shape as one repository holding two native apps: the Android app this page walks, and an iOS twin that consumes the identical compiled core. Each section below names the iOS counterpart of the part it describes. This page walks the tree the way [How Android apps work](/articles/how-android-apps-work) walks a standard Android project — read that page first if manifests, activities and Gradle modules are new vocabulary. The worked example throughout is **[Memory Lane](https://memorylaneapp.lovable.app/)**, a shared-memories app whose production iOS app was joined by a full Android twin built on Duet — the same codebase [Handling platform-specific UI](/articles/cross-platform-ui-parity) quotes. Memory Lane is live on the [App Store](https://apps.apple.com/us/app/memory-lane-private-journal/id6760589051); every file name and count below comes from that repository. ## What does the tree look like? One repository holds both apps and the contract between them. The Android-relevant parts of Memory Lane's tree: ```text theme={null} src-kmp/ # the Gradle plane: shared core + the Android app ├── subtrees/ │ ├── timeline/logic/ # one module per feature: State, Action, Effect, reducer │ ├── capture/logic/ # …16 feature modules in this app │ └── priming/logic/ ├── services/ # cross-cutting ports: identity, feeds, consent ├── telemetry/ # the closed analytics event grammar ├── theming/ # design tokens, shared with the iPhone app ├── apple-umbrella/ # Kotlin/Native framework for the iPhone app ├── replay-runner/ # replays recorded fixtures on the JVM └── app/ # the Android app module ├── MainActivity.kt # the Android edge: intents, Bundle I/O, retained tree ├── AppRoot.kt # the Compose root ├── MainNavHost.kt # the kind → renderer table for the main host ├── Builder.kt # one builder per mounted feature ├── ui/ # theme and controls ├── workers/ # the I/O: repositories, push, media └── widgets/ # home-screen widgets src-ios/ # the iPhone app; consumes the same core parity/ ├── feature-specs/ # one one-pager per feature └── fixtures/ # recorded behavior — build products, never hand-edited ``` Everything under `src-kmp/` is an ordinary Gradle build you can open in Android Studio. The one module with no Android role is `apple-umbrella`, which packages the same feature modules as a Kotlin/Native framework for the iPhone app. ## Where does feature logic live? Each feature is one Gradle module under `subtrees/`, compiled to `commonMain` — Kotlin with no Android or iOS imports. A feature is three declarations and a function: a serializable **state**, the **actions** that can change it, the **effects** it may request, and a pure [reducer](/articles/duet-glossary#reducer). Memory Lane's smallest feature, the notification soft-ask, in skeleton (the full file is 103 lines): ```kotlin PrimingFeature.kt (commonMain) theme={null} @Serializable data class PrimingState( val outcome: PrimingOutcome? = null, // the user's choice, latched ) sealed interface PrimingAction { data object EnableTapped : PrimingAction data object NotNowTapped : PrimingAction } sealed interface PrimingEffectPayload { data object RequestPushPermission : PrimingEffectPayload data class NotifyListener(val event: PrimingDelegateEvent) : PrimingEffectPayload } fun primingReducer( state: PrimingState, action: PrimingAction, ): Reduced = when (action) { PrimingAction.EnableTapped -> if (state.outcome != null) { Reduced(state) // the first verb won; later taps are inert } else { Reduced( state.copy(outcome = PrimingOutcome.enabled), listOf( Effect.Run(PrimingEffectPayload.RequestPushPermission), Effect.Run(PrimingEffectPayload.NotifyListener(PrimingDelegateEvent.EnableChosen)))) } PrimingAction.NotNowTapped -> if (state.outcome != null) { Reduced(state) } else { Reduced( state.copy(outcome = PrimingOutcome.declined), listOf(Effect.Run(PrimingEffectPayload.NotifyListener(PrimingDelegateEvent.Declined)))) } } ``` The reducer takes no environment — no clock, no network, no permission API. The platform permission prompt is requested as an *effect value*; a worker in the app module performs it. That separation is what makes the feature recordable: a [scenario](/articles/duet-glossary#scenario) drives the reducer and records state and effects into `parity/fixtures/priming.*`, and both the Android app's JVM test lane and the iPhone app's Swift lane replay those exact bytes in CI. This layer has no hand-written iOS counterpart: the same module compiles into the `apple-umbrella` framework, and the iPhone app calls the same reducer through it. The app module consumes the features as ordinary project dependencies — this is the whole wiring, from Memory Lane's `app/build.gradle.kts`: ```kotlin app/build.gradle.kts theme={null} dependencies { implementation(project(":subtrees:timeline:logic")) implementation(project(":subtrees:memorydetail:logic")) implementation(project(":subtrees:mainnav:logic")) implementation(project(":subtrees:rootnav:logic")) implementation(project(":subtrees:registration:logic")) implementation(project(":subtrees:introduceyourself:logic")) implementation(project(":subtrees:onboarding:logic")) implementation(project(":subtrees:accept:logic")) implementation(project(":subtrees:enterinvitecode:logic")) implementation(project(":subtrees:profile:logic")) implementation(project(":subtrees:shared:logic")) implementation(project(":subtrees:capture:logic")) implementation(project(":subtrees:invitecode:logic")) implementation(project(":subtrees:priming:logic")) implementation(project(":subtrees:sharepicker:contract")) implementation(project(":subtrees:sharepicker:logic")) // …theming, services, telemetry } ``` ## What is in the Android app module? `app/` holds the three kinds of code that are platform work by nature: * **The Android edge.** One activity. It owns exactly what only an activity can: the splash window, intent ingress (deep links, notification taps), saved-instance `Bundle` I/O, and the handle to a retained component tree that survives rotation. Its iOS counterpart is the scene component in the iPhone app target — the same thin glue, owning only what the platform's entry object must. Memory Lane's `MainActivity`, trimmed to its structure: ```kotlin MainActivity.kt theme={null} class MainActivity : ComponentActivity() { private lateinit var retained: RetainedRoot override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() super.onCreate(savedInstanceState) enableEdgeToEdge() // Rotation recreates the Activity; getOrCreate returns the same tree. val restoredSpineJson = savedInstanceState?.getString(SPINE_KEY) retained = instanceKeeper().getOrCreate { RetainedRoot(Dispatchers.Main.immediate, RootBuilder::teardown) { scope -> RootBuilder( restoredSpineJson = restoredSpineJson, composition = RootComponent(ActivityRootDependency(applicationContext, scope)), scope = scope, ) } } // URL taps on a fresh launch only: recreation re-delivers the original // intent, and a stale invite link must not re-enter the accept flow. if (savedInstanceState == null) { intent?.deepLinkUrl()?.let(retained.component::onUrl) } setContent { MemoryLaneTheme { AppRoot(retained.component.shell) } } } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) // Navigation is state, so the route spine rides the Bundle like any value. retained.component.captureSpineJson()?.let { outState.putString(SPINE_KEY, it) } } } ``` * **Builders and shells.** One builder per mounted feature (`TimelineBuilder.kt`, `CaptureBuilder.kt`, …) constructs the feature's store and its Compose [shell](/articles/duet-glossary#shell) — the screen that reads state and sends actions. Navigation hosts like `MainNavHost.kt` bind each shared navigation *kind* to a Material renderer. The iOS counterparts are the SwiftUI builders and shells in the iPhone app — `MainNavHost.kt`'s twin is `MainNavShell.swift`, binding the same kinds to sheets and stacks; [Handling platform-specific UI](/articles/cross-platform-ui-parity) shows the two tables side by side. * **Workers.** Everything that touches the world — repositories over the backend, push messaging, media capture, image downscaling — implements a port the reducers name as effects ([worker](/articles/duet-glossary#worker) in the glossary). Memory Lane's `workers/` directory is Firebase clients, `MediaRecorder` sessions and notification plumbing: ordinary Android code behind interfaces the shared core defines. The iOS twin implements the same ports in Swift — AVFoundation sessions and Apple push plumbing behind the identical interfaces. The app module contains no feature logic. A saving rule, a permission latch, a navigation decision — each lives once, in its `commonMain` module, where the iPhone app compiles the identical code. When Memory Lane's sixteen Android screens were written against the already-recorded core, zero logic files changed. Side by side: Memory Lane's registration screen in SwiftUI on an iPhone — hand-drawn clouds, a portrait illustration, Continue with Apple and Continue with Google buttons — and in Jetpack Compose on a Pixel 8 emulator — the same serif tagline with a single Continue with Google button ## What does day one look like, before any of this grows? The tree above is a shipped app's grown state. On the day the scaffold runs, the same shape is already there at minimal size: one sample feature module, an app module whose root mounts that single feature ([mount](/articles/duet-glossary#mount) in the glossary), one worker file, the theming and telemetry modules, and a CI workflow that records and verifies on the first push. Growth is additive — each new feature is a new `subtrees/` module and a new builder, and the mount harness gives way to a real navigation root when the second screen arrives. The structure Memory Lane ships with is the structure the scaffold created, sixteen features later. The platform menu of the Modaal new-project sentence: iOS (iPhone now, Android later) and Android + iOS (One codebase, both stores, checked), plus a checkbox row Add Apple Watch companion ## What about widgets and background work? Home-screen widgets are ordinary Android surfaces in the app module: Memory Lane ships a feed widget and a quick-capture widget that render *projections* of shared state — plain values a sync worker writes — rather than mounting reducers of their own. The iOS twin ships its widgets the same way, as a WidgetKit extension beside the app target. Background work follows the platform's rules described in [How Android apps work](/articles/how-android-apps-work#what-runs-in-the-background): push messages arrive through a service, deferred work goes through scheduled workers, and each of them enters the shared core the same way the UI does — by sending actions. ## Common questions Structurally yes — it is Gradle, `commonMain` modules and a Kotlin/Native framework, with nothing proprietary in the tree. What the scaffold fixes on top of raw KMP is the feature shape (state, actions, effects, one reducer per module), the recorded fixtures as the behavior contract, and the CI gate that replays them on both platforms. Those conventions are the part a from-scratch setup has to invent and enforce. Yes. `src-kmp/` is a standard Gradle build: Android Studio opens it, indexes it, runs the app and the JVM tests. The iPhone half opens in Xcode from the same repository. Under `src-ios/`, as an ordinary Xcode project. It consumes the identical compiled feature modules through the `apple-umbrella` Kotlin/Native framework — there is no Swift re-implementation of any reducer in the repository. [Kotlin/Native GC and Swift ARC](/articles/kotlin-native-gc-swift-arc) describes how that framework's collector and the app's Swift code share objects. Because navigation is feature state, not activity plumbing. Which screen is showing, which sheet is up, what the back stack holds — each is a value a reducer owns, so it is recorded and replayed like any other behavior, and the activity's job shrinks to hosting the Compose tree and persisting the route spine so a system-killed process comes back on the same screen. Measured on Memory Lane: about 3 MB for the Kotlin Multiplatform core in the Android app. The iPhone app carries the Kotlin/Native framework as its equivalent cost. [Modaal](https://modaal.dev) scaffolds this tree for you — say you're building a **Production app** for **Android + iOS** in the sentence on the home screen. ## Sources and further reading * [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html) — the language feature the shared core builds on * [Guide to app architecture](https://developer.android.com/topic/architecture) — Google's recommendation the feature shape aligns with: unidirectional data flow, state as data * [Guide to Android app modularization](https://developer.android.com/topic/modularization) — the per-feature Gradle module pattern in general form * [Save UI states](https://developer.android.com/topic/libraries/architecture/saving-states) — the saved-state contract the route spine rides * [The Duet framework on GitHub](https://github.com/modaal-agent/duet) — both flavors, the toolchain and the versioned contracts ## Read next The same repository's iPhone side: the Kit package over the Kotlin framework, the Main package, theming and the app target. The standard anatomy this page builds on: the manifest, activities, Compose and the Gradle build. What Duet fixes in place that a blank Android project leaves open, and what that buys in tests. What Duet is, the two combinations that scaffold it, and how a feature gets built. Every term on this page — reducer, shell, worker, mount, fixture — defined with one link each. This tree built by hand: the first Compose shell, host and Activity in Tutorial 2, the workers in Tutorial 4, the back policy and the route restore in Tutorial 5. # Duet Glossary: Every Term in the Cross-Platform Framework Source: https://docs.modaal.dev/articles/duet-glossary Every Duet term defined in one place — feature, reducer, behavior recording, the checks, the coexistence window — and the name the tools use for each. This glossary defines every term used by **Duet**, Modaal's cross-platform parity framework for native iOS and Android apps: the parts of a feature, the recording-and-checking vocabulary, the steps of a migration, and the work items a migration is planned in. Each entry is one paragraph with a stable anchor and a link to the page that treats the subject in full; where one of the [nine Duet tutorials](/tutorials/duet) builds the thing by hand, the entry names that page too. The entry heading is the name the documentation uses. Where the tools, the parity manifest or the framework's own pages use a different name for the same thing — *fixture* for behavior recording, *gate* for check, *ledger* for tracker, *oracle* for reference test — the entry states both names. ## The parts of a feature ### feature A unit of behavior declared as state, actions, effect payloads and a pure reducer, carried as one row in [the parity manifest](#manifest) and checked by its own [behavior recordings](#fixture). See [how a feature gets built](/articles/duet#how-a-feature-gets-built). ### reducer Where every decision happens: for each action the [Store](#store) feeds it, the reducer alone runs the guards, writes state and returns effects. It is pure — no platform calls, no minted time or identity — so its behavior [replays](#replay) byte-for-byte on both platforms. ### effect / effect payload What a [reducer](#reducer) returns beside the new state: a value naming work the platform must do, such as a wait on a clock, a port call or a delegate notification, never the work itself. [The effect handler](#effect-handler) turns each payload into the actions it produces, and an effect id gives it cancel-in-flight semantics. Because an effect is data, a [behavior recording](#fixture) pins it byte for byte. [Tutorial 1](/tutorials/duet-01-first-feature#add-the-environment-and-the-runtime) writes the first two; [Tutorial 9](/tutorials/duet-09-analytics#emit-them-as-track-effects) adds an analytics event as one more. ### the effect handler The module's only impure code: it takes each [effect payload](#effect) the [reducer](#reducer) returned and turns it into the stream of actions that payload produces, doing the work through [the environment](#environment). It receives a reducer's effects in the order the reducer returned them, and that order is part of what a [behavior recording](#fixture) pins. [Tutorial 1](/tutorials/duet-01-first-feature#add-the-environment-and-the-runtime) writes the first one, beside the store factory both shells later call. ### the environment The interface of seams a feature's work goes through — the clock, identity, storage, the feature's [ports](#port) — held by [the effect handler](#effect-handler) and consulted nowhere else; the [reducer](#reducer) never sees it, which is what lets a recording replay without one. A feature with no seam has no environment, and that is a valid shape. Its mechanical test double is generated rather than hand-written; a hand-written fake enters only where the double needs behavior of its own, such as driving a port's streams. [Tutorial 1](/tutorials/duet-01-first-feature#add-the-environment-and-the-runtime) declares the first environment against a hand-written double; [Tutorial 3](/tutorials/duet-03-composing-features#generate-the-kotlin-test-doubles) switches to generated ones. ### Store Built once per [mount](#mount): it holds the feature's state, takes actions, runs the [reducer](#reducer), and hands returned effects to [the effect handler](#effect-handler). Nothing else runs feature logic. [Tutorial 1](/tutorials/duet-01-first-feature#add-the-environment-and-the-runtime) builds the first one and drives it through the kernel's exhaustive [test store](#test-store). ### kernel The runtime every [Store](#store) is built on: it runs each action through the reducer, tracks returned effects in flight, and owns the effect-leak check at teardown. One kernel shape serves both platforms; its contract ships with [the framework](https://github.com/modaal-agent/duet). ### shell What stays on the platform side once logic has moved: three duties and no decisions — bind the view's intents to the store, project state into the view, bracket the lifecycle. See [what crossing one feature looks like](/articles/combineribs-to-duet#what-crossing-one-feature-looks-like); [Tutorial 2](/tutorials/duet-02-two-apps#write-the-swift-shell) writes the first Swift and Compose shells. ### host / host root The level that [mounts](#mount) a feature: it builds the store, owns the slot the feature renders in, and takes its [delegate events](#delegate) as data. In the Swift flavor a *host root* is the package whose resolved dependencies must contain the Duet family and nothing else. [Tutorial 3](/tutorials/duet-03-composing-features#compose-the-tree-on-android) mounts a tree of features from state, one level hosting the next. ### delegate events / the delegate seam How a feature reports an outcome upward: the reducer emits a plain enum case, and the [host](#host) receives it as data on the seam it handed in at build time. The event is a value, so a [scenario](#scenario) asserts it byte-for-byte. [Tutorial 3](/tutorials/duet-03-composing-features#write-the-root-level) receives each child's delegate events as one case of the parent's action type. ### Interactor The CombineRIBs type that owns a RIB's business logic. In a migration every Interactor shrinks to a [shell](#shell) and is then deleted. See [what happens to each piece of your code](/articles/combineribs-to-duet#what-happens-to-each-piece-of-your-code). ### worker Stateful, lifecycle-bound processing on the environment side, adopted at [mount](#mount): its results re-enter feature state only through declared seams, and cancellation is its stop. Workers process, reducers decide; a worker has no [golden recording](#golden-fixture). [Tutorial 4](/tutorials/duet-04-workers#write-the-workers-on-android) writes the first two and tests them with [the worker harness](#worker-harness). ### the shared core The one place a feature's logic lives in the Kotlin Multiplatform flavor: Kotlin `commonMain` modules under `src-kmp/`, one subtree module per [feature](#feature). Android compiles it directly, and iOS consumes the same compiled logic as one prebuilt framework Gradle assembles, so the [reducer](#reducer) the iOS app runs is the one the Kotlin lane checked rather than a second copy of it. That framework links the Kotlin/Native garbage collector into the iOS app beside Swift's ARC; [Kotlin/Native GC and Swift ARC](/articles/kotlin-native-gc-swift-arc) covers who frees what. [Wiring the core](#wiring-the-core) is the migration step that links it into an existing iOS build, and [single-source](#single-source) is the shape it exists to produce. [Tutorial 2](/tutorials/duet-02-two-apps#declare-the-apple-framework-module) builds the first one into an Apple framework and [replays the recordings across it](/tutorials/duet-02-two-apps#replay-the-recordings-across-the-boundary). ### package In the Swift flavor a feature's subtree holds two Swift packages: the gated feature half resolves the Duet family and nothing else, so the checks build it in isolation; the app-facing node half is where the app connects. ### port An interface the shared logic calls and each app implements, such as the auth, purchases, items and account ports in the tutorials' app. Every operation starts work and returns, with the answer arriving through a callback that fires once, so a Swift class can implement it across the framework boundary; a stream on a port is a sticky value a [worker](#worker) observes. [Tutorial 3](/tutorials/duet-03-composing-features#declare-the-four-ports) declares the four ports behind mock services; [Tutorial 4](/tutorials/duet-04-workers#add-the-two-streams-to-the-ports) adds the streams and replaces the mocks with an on-device backend. ### slice A value a parent publishes downward out of its own state, delivered to a child as an action of the child's own: the child reads it and never writes it, and nothing below the parent holds the parent's [Store](#store). [Tutorial 4](/tutorials/duet-04-workers#project-the-entitlement-down-as-a-slice) projects the entitlement to two tabs as a slice. ### lateral state A value that must cross between sibling features, neither of which holds the other's store: one [worker](#worker) owned by their lowest common ancestor, with a void update on one side and a sticky observation on the other. [Tutorial 5](/tutorials/duet-05-navigation-as-state#share-readiness-laterally) builds one for the onboarding steps' readiness. ### `@MainActor` The escalation a handler or worker takes to cross an isolation boundary. Marking a type unchecked-`Sendable` instead silences the compiler without proving anything, which is why [the doctor pass](#doctor) flags it on a worker. ### the fixture-authoring language The four verbs a [scenario](#scenario) is written in — given, when, then, then-effects — with a Swift and a Kotlin spelling. The framework's pages call it the scenario DSL. ## Recording and checking ### the parity manifest `parity/manifest.yaml` at the repository root: one row per [feature](#feature) naming the file that implements it, its state, action and effect-payload types, the [scenario](#scenario) its recordings compile from, and the [recordings](#fixture) it owns, plus the generator rows for the tree's generated doubles and the divergence waivers [the parity contract keeps](/articles/cross-platform-ui-parity#divergence-is-ledgered-not-forbidden). The file's presence is what marks a tree as a Duet tree, and its header comment names the flavor. Every check is derived from it and nothing is guessed at: [the lanes](#lane), [which platform lanes run](#swift-and-kotlin-lanes), and which recordings must report a [replay](#replay). [Tutorial 1](/tutorials/duet-01-first-feature#declare-the-feature-in-the-manifest-and-write-its-spec) declares the first feature in it; [Tutorial 6](/tutorials/duet-06-checks-in-ci#read-the-lanes-the-manifest-derives) reads the lanes it derives. ### the feature spec One page per [feature](#feature) in prose, under `parity/feature-specs/`, in eight fixed sections: identity and config, state, actions, transitions, effects, delegate events, what stays app-side, and the recordings. It is where a behavior change is written first, before the [scenario](#scenario) that makes it executable. The tool checks that every recording [the manifest](#manifest) lists is named in the spec in backticks, so the prose cannot drift from the recordings without a red check; where spec and recording disagree about behavior, [the recording wins](#fixture). [Tutorial 1](/tutorials/duet-01-first-feature#declare-the-feature-in-the-manifest-and-write-its-spec) writes the first one, and [the readiness audit](#audit) drafts one per feature from a legacy app. ### scenario One test in [the fixture-authoring language](#dsl), pinning one outcome. Mutually exclusive endings are branches over one starting state; a chain scenario pins a [delegate seam](#delegate) that crosses features. [Tutorial 1](/tutorials/duet-01-first-feature#describe-the-behavior-as-a-scenario-and-record-it) writes the first scenario and records it. ### behavior recording What recording a [scenario](#scenario) produces: a checked-in file replaying the scenario's exact state and effect bytes — a build product, never hand-edited, and it wins any disagreement with [the feature spec](#feature-spec)'s prose. The tools, the manifest and the framework's pages call these files fixtures. ### canonical bytes The one JSON dialect both platforms encode to, so a [recording](#fixture) can be compared as text rather than as two language-specific object graphs: keys sorted at every nesting level, no insignificant whitespace, a nil-valued key omitted rather than written as `null`, dates as ISO-8601 UTC with exactly three fractional digits, UUIDs lowercased, and a sum type written as an explicit case-and-value pair instead of either language's default encoding. Both sides of every comparison are re-encoded through the same writer before they are compared, so a committed file may be pretty-printed for a reader without changing what it pins. Floating-point values are not allowed in recorded types, because no cross-platform representation rule is pinned for them yet. The framework's pages call this canonical serialization. ### replay What the checks do with a committed [behavior recording](#fixture): seed the recording's initial state, send each recorded action through the reducer, compare the [canonical](#canonical) state and effect bytes against the recording's expectations, and stop at the first step that differs, so a red names the recording and the step. The same files replay in three places — a feature's [platform lanes](#swift-and-kotlin-lanes), where the [golden test](#golden-fixture) reads the committed files at runtime; the Apple boundary lane, which replays them across the framework the iOS app consumes; and `duet protocol-run`, which drives them into a replay-runner binary over the framework's replay protocol. Replay checks the reducer against the recorded bytes; [re-recording](#re-recording) in check mode runs the opposite direction, checking the recorded bytes against the scenario. [Tutorial 1](/tutorials/duet-01-first-feature#replay-the-recordings) writes the golden test and [adds the replay runner](/tutorials/duet-01-first-feature#add-the-replay-runner); [Tutorial 2](/tutorials/duet-02-two-apps#replay-the-recordings-across-the-boundary) replays the same four recordings across the boundary; [Tutorial 6](/tutorials/duet-06-checks-in-ci#know-what-each-command-proves) lists which command replays what. ### golden recording A [behavior recording](#fixture) in its [replay](#replay) role: the golden test reads committed recordings at runtime and fails on any byte that differs — a role, not a second kind of file. A [worker](#worker) has no reducer to replay, so it has no golden. ### chain recording A [behavior recording](#fixture) that pins the seam between two features rather than one reducer: the delegate event one reducer emits, received by the next as its action, with each hop re-derived at verify time from the recorded bytes so an edited seam fails as structure drift. [Tutorial 3](/tutorials/duet-03-composing-features#pin-the-seams-with-chain-recordings) records the first four. ### re-recording Running the recorder again after a deliberate behavior change, then reading the recording diff like code. The run deletes before it harvests, so a leftover file means a stale run. ### metadata-only The pass condition for a re-record that must not change behavior: the diff touches scenario source locations and step labels, and zero state or effect bytes. ### the test store The exhaustive harness for what a [recording](#fixture) does not cover: it drives the real [Store](#store), [effect handler](#effect-handler) and [environment](#environment) together on a virtual clock, so timing and effect lifetimes are testable without waiting. Every state change must be asserted and every effect expected — an unreceived action or an effect still running fails at `finish()`. [Tutorial 1](/tutorials/duet-01-first-feature#pin-the-safety-nets-timing-on-a-test-clock) pins the splash's one piece of timing on it. ### the worker harness The only sanctioned test bracket for a [worker](#worker), and what stands in for the [golden recording](#golden-fixture) a worker cannot have: start the worker, drive the streams it observes, cancel it, and `finish()` — which fails when the worker's run has not returned after cancellation, the leak a hand-rolled start-then-cancel bracket does not catch. [Tutorial 4](/tutorials/duet-04-workers#test-the-workers-with-the-harness) tests the first two workers with it. ### mutation drill The negative control on [the checks](#gate): break a reducer guard and watch the replay go red, then break a recording file and watch the test task go red — proof the checks are wired to what they claim to cover. Run in [a crossing's first half](/articles/combineribs-to-duet#what-crossing-one-feature-looks-like); [Tutorial 6](/tutorials/duet-06-checks-in-ci#write-the-first-mutation-rows) runs it from a mutation table in both directions. ### the checks What must pass before work is done, run by one command locally and in CI: [the manifest lint](#duet-lint), the recording check, the cross-platform verification and [the doctor pass](#doctor). The framework's pages call each one a gate. [Tutorial 6](/tutorials/duet-06-checks-in-ci#know-what-each-command-proves) lists every check with what it proves and [puts them in one CI workflow](/tutorials/duet-06-checks-in-ci#put-the-checks-in-a-workflow). ### lane One slice of a [checks](#gate) run, rooted at a specific package or module set, so a red names its platform and its root. Parity lanes derive from a feature's [manifest](#manifest) row; generation lanes produce the test doubles and DI components. [Tutorial 6](/tutorials/duet-06-checks-in-ci#read-the-lanes-the-manifest-derives) reads the lanes report the tool derives. ### the Swift and Kotlin lanes A [manifest](#manifest) row's two implementation declarations; which of them a row carries tells [the checks](#gate) which platform lanes to run — one during ordinary work, both inside a [coexistence window](#coexistence-window). ### the manifest lint [The manifest](#manifest)'s own consistency checks — the fast red, run on every manifest edit. The declaration-parity and subtree-geometry checks join only while a row declares both implementations. ### the doctor pass The check that reads the tree rather than [the manifest](#manifest): it confirms the project's own declaration file still matches the tree it describes, and flags the shortcuts the byte checks cannot see — a [worker](#worker) stamped unchecked-`Sendable` to silence the compiler instead of [taking the escalation](#main-actor). A red names the declaration row, or the worker. [Tutorial 6](/tutorials/duet-06-checks-in-ci#know-what-each-command-proves) lists it beside the other nine commands with what each one proves. ### the headless test runner A script every scaffolded project ships that runs the iOS test suite from the command line, wrapping Apple's xcodebuild — for verifying outside the Modaal app and reproducing CI failures locally. ### the run verb The build-and-launch action in a Modaal session: one command that builds the selected target and launches it on a simulator, emulator or device. The action's name differs between session types, so Duet's documents say "the run verb". ## The migration, step by step ### the readiness audit A static census of a legacy app's distance to the target shape, producing the readiness report, [the migration tracker](#migration-ledger) and a [feature spec](#feature-spec) draft per feature. The counts are pointers to verify, not verdicts. See [the stages](/articles/combineribs-to-duet#the-stages), step 1. ### the migration tracker One row per [surface](#surface): a derived status, four sizing columns, and the notes the per-feature plan is written into. The tools and the migration article call it the ledger — distinct from the divergence waivers [the parity contract keeps](/articles/cross-platform-ui-parity#divergence-is-ledgered-not-forbidden). ### updating the tracker Re-running [the readiness audit](#audit) in write mode: it creates or merges the tracker and refreshes the computed fields. It is the route's first step after [the stop-digging edit](#stop-digging-rule). ### the graft The one operation that adds [the shared core](#core)'s Kotlin plane, the Android app shell and the parity workspace beside an existing tree while editing none of its sources — the checks exist before any behavior can change. See [the stages](/articles/combineribs-to-duet#the-stages), step 2. ### wiring the core Run once the graft's checks are green: it links [the assembled shared core](#core) into the iOS app's build, so every later [flip](#the-flip) is feature work with no build wiring left to add. See [the stages](/articles/combineribs-to-duet#the-stages), step 2b. ### feature crossing One feature's move onto [the shared core](#core), end to end: [spec](#feature-spec), Swift [twin](#twin), Kotlin twin, the Android [mount](#mount), the [flip](#the-flip). See [what crossing one feature looks like](/articles/combineribs-to-duet#what-crossing-one-feature-looks-like). ### the coexistence window The stretch of a crossing in which a feature's [manifest](#manifest) row declares both implementations: both [twins](#twin) replay the same recordings and the parity lints run. A valid resting state for as long as the repository wants. ### the flip The one change that closes a [coexistence window](#coexistence-window): delete the Swift twin, make the feature's [manifest](#manifest) row Kotlin-only, verify both platforms. ### twin One of a feature's two implementations while its [coexistence window](#coexistence-window) is open — the Swift twin inside the app, the Kotlin twin in [the shared core](#core) — replaying the same [behavior recordings](#fixture). In a [Duet Twins](/articles/duet-twins) project, in development, both twins are permanent. ### single-source The destination shape: one implementation of a feature's logic, in [the shared core](#core), consumed by both apps. The Swift flavor's *single-core* is the same one-copy rule before a second platform consumes it. See [the two Duet templates](/articles/duet#the-two-duet-templates). ## The work items and the field kit ### surface A [tracker](#migration-ledger) row's unit of work: one RIB or one legacy worker in the app being converted, typed by the row's kind. ### reference test The existing test a conversion is written against, ported one-to-one onto the deterministic test store or the worker test harness. A surface without one earns its reference test first; the audit's sizing table and the tracker call it the oracle. ### look-and-feel work The part of a [surface](#surface) that does not move: tuned rendering, animation timing, gesture handling — re-authored on the other platform and sized by counting files. The audit calls it platform-manner mass; [manner](/articles/cross-platform-ui-parity#the-seam-a-five-layer-presentation-contract) is the contract layer it lives in. ### the note format The shape every [tracker](#migration-ledger) note takes: a capitalized tag, an em dash, then clauses following the surface's kind — a RIB's note carries a crossing position; a worker's names the crossing it rides with. ### the stop-digging rule From step 0 on, a new feature in the legacy app is authored in the target shape — a reducer plus a thin shell, never a new Router — and new background work is a Duet [worker](#worker) from day one. See [the stages](/articles/combineribs-to-duet#the-stages), step 0. ### adoption seam Where a converted feature's SwiftUI view attaches to the legacy UIKit composition: a themed hosting controller takes the legacy child's slot, and activating the [shell](#shell) replaces attaching the child. ### coexistence kit The migration package that stands for the migration's duration — the worker shim and the dual-import discipline — and whose dependency edge is deleted at completion. See [the finish line](/articles/combineribs-to-duet#the-finish-line). ### stage host What the Android app's root grows into when the app needs real navigation: one full-screen child chosen from route state, transitions as reducer edges, back handling in a documented order. The first [crossing](#feature-crossing) that needs a screen change grows it. ### mount Putting a feature into the app's composition: its [Store](#store) is built once at the mounting level, its screen renders from the mount row, and that level's own [workers](#worker) are adopted there. ### spine The app's navigation trunk, in two uses: the route state that survives process death, and the root and navigation surfaces that cross last, after the leaves. [Tutorial 5](/tutorials/duet-05-navigation-as-state#gather-the-route-spine-and-restore-from-it) gathers a route spine and rebuilds the tree from it. ## Common questions They are the same file. The headings here use the documentation's names; the tools, the parity manifest and the framework's pages use the framework-side names — fixture, gate, ledger, oracle — and each entry states both: [behavior recording](#fixture), [the checks](#gate), [the migration tracker](#migration-ledger), [reference test](#oracle). As long as the repository wants — [the window](#coexistence-window) is a valid resting state, and the parity lints keep both twins honest while it is open. Holding one open costs the dual-language authoring premium the [migration article](/articles/combineribs-to-duet#what-crossing-one-feature-looks-like) quantifies; [the flip](#the-flip) closes it. Three articles carry the depth: [the Duet overview](/articles/duet) for the feature shape and the two templates, [handling platform-specific UI](/articles/cross-platform-ui-parity) for the presentation contract, and [migrating a CombineRIBs app](/articles/combineribs-to-duet) for the route. The framework's own reference lives in [the Duet repository](https://github.com/modaal-agent/duet). The [nine Duet tutorials](/tutorials/duet) build each feature-level term by hand, and the [series index](/tutorials/duet#what-does-the-series-teach) maps every concept to the step that treats it. In [Modaal](https://modaal.dev), say you're building a **Production app** — for **Android + iOS** or for **iOS** — in the sentence on the home screen. [Starting a Duet project](/articles/duet#starting-a-duet-project) walks through the choice. ## Sources and further reading * [The Duet framework repository](https://github.com/modaal-agent/duet) — the framework API, the emitted contracts and the family's own documentation. * [Kotlin Multiplatform documentation](https://kotlinlang.org/docs/multiplatform.html) — the technology under the Kotlin Multiplatform flavor's shared core. * [Swift concurrency documentation](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/) — the isolation model behind the [`@MainActor`](#main-actor) entry. ## Read next The product overview: what a Duet project is, the two templates, and how a feature gets built. The five-layer presentation contract: kinds, manner, islands, and how deliberate divergence is recorded. The route the migration cluster's entries walk: audit, graft, crossings, the finish line. Feature, reducer, shell, worker, scenario, recording and the checks, each built by hand across nine tutorials. # The Structure of a Kotlin Multiplatform iPhone App Source: https://docs.modaal.dev/articles/duet-ios-app-anatomy A Duet iPhone app is a SwiftUI app over a prebuilt Kotlin core: a Kit package that links it, a Main package that composes the app, and a thin app target. A Duet-built iPhone app on the Kotlin Multiplatform flavor is a SwiftUI app over a prebuilt Kotlin core. The feature logic runs inside one XCFramework that Gradle assembles from the shared Kotlin modules. The Swift code around it is split into Swift packages: a **Kit** package that links the framework and adapts each Kotlin store for SwiftUI, a **Main** package that composes the app, two theming packages, and an app target that holds only what the operating system's entry objects must. **Duet**, Modaal's cross-platform parity framework for native iOS and Android apps, scaffolds this shape. This page walks the `src-ios/` tree; [Inside a Duet Android app](/articles/duet-android-app-anatomy) walks the same repository's Android side. The worked example is **[Memory Lane](https://memorylaneapp.lovable.app/)**, live on the [App Store](https://apps.apple.com/us/app/memory-lane-private-journal/id6760589051), the same app the Android page walks. Its package names begin with the project's name; this page writes that prefix as ``, the placeholder the scaffold fills with your app's name. Counts come from Memory Lane's repository. ## What does the tree look like? The iPhone-relevant parts of Memory Lane's tree: ```text theme={null} src-kmp/ └── apple-umbrella/ # builds the Kotlin core as Kit.xcframework src-ios/ ├── App/ │ ├── xcodegen.yml # the Xcode project spec; the .xcodeproj is generated │ ├── xcconfig/ # Debug, AdHoc and AppStore build settings │ ├── / # the app target: AppDelegate, SceneDelegate, SceneComponent │ └── Widgets/ # a WidgetKit extension ├── Libraries/ │ ├── Kit/ # the binary target over the XCFramework, and the bridge │ ├── Main/ # the composition root, shells, views, workers │ ├── Theming/ # the theme and its design tokens │ ├── WidgetShared/ # data the app and the widget extension share │ └── MemoryLaneConnector/ # a generated backend client └── Subtrees/ └── Priming/PrimingNode/ # one feature's iOS shell as its own package scripts/ └── assemble_kit.sh # assembles the core; every iOS build runs it first parity/fixtures/ # the recordings the Kotlin and Swift lanes replay ``` The code under `src-ios/` is Swift. The iPhone app contains no copy of a reducer: the feature logic is compiled once from `src-kmp/` and linked as the XCFramework. Memory Lane's `src-ios/` holds 250 Swift files outside generated directories, 208 of them in the Main package. ## What is in the Kit package? `src-ios/Libraries/Kit/` is where Swift meets the Kotlin core. Its manifest declares the framework as a binary target at the path `assemble_kit.sh` publishes to, and a bridge target that every shell depends on: ```swift src-ios/Libraries/Kit/Package.swift (as the scaffold emits it) theme={null} .binaryTarget( name: "Kit", path: "../../../src-kmp/apple-umbrella/build/XCFrameworks/app/Kit.xcframework" ), .target( name: "Bridge", dependencies: [ .target(name: "Kit"), .product(name: "DuetShells", package: "duet"), ], linkerSettings: [ .linkedLibrary("c++"), .linkedFramework("Foundation"), ] ), ``` * **The binary target** is a build product of the project's own Kotlin sources. `build/` is not committed, so a fresh clone has no framework until the first assemble; [How do you build it?](#how-do-you-build-it) covers when that happens. * **The bridge** holds `BridgedStore`, the one class every shell uses to hold a Kotlin store: a `@Published` mirror of the store's state, a synchronous `send`, and a `cancel()` that stops the Kotlin runtime. [Tutorial 2](/tutorials/duet-02-two-apps#write-the-consumer-package-and-the-store-mirror) writes it. * **The shells.** The scaffold emits each feature's iOS side into this package as a `Shell` target: a Builder that makes the Kotlin store and the bridged mirror, a `ViewShell` that turns intents into actions and state into view state, the SwiftUI view, and a spec target that tests the shell. Memory Lane keeps its 14 shell directories in the Main package's `Shells/` instead; both layouts depend on the same bridge. The Kotlin/Native collector frees a Kotlin object that Swift holds, on a collection after the last Swift reference is released. [Kotlin/Native GC and Swift ARC](/articles/kotlin-native-gc-swift-arc) covers what that framework links into the app and who frees what. ## What is in the Main package? `src-ios/Libraries/Main/` composes the app. It depends on the Kit package, the two theming packages and the Duet shells and services packages, and it holds: * **The composition root.** `RootBuilder` builds the root level: a `RootComponent` that owns the app-lifetime services (diagnostics, analytics, the inbound URL and push registry), adopts them as workers in a `StoreHost`, and then mounts the features. Composition is a Dependency, a Component and a Builder per level, the same three parts the Android app uses ([Tutorial 3](/tutorials/duet-03-composing-features)). * **Mounts.** The scaffold writes one `Features/Mount.swift` per feature: a function that calls the feature's Builder, wraps the view in a `MountedFeature`, and gives the scene the mount's `activate` and `deactivate` bracket. A child's delegate events are routed in that file's `onDelegate` closure. * **The root screen.** `RootScreen` renders the mounted features. As emitted it shows one feature full-screen, with a tab strip once there are two; the app's real navigation replaces it as the product grows. * **Workers.** Everything that touches the world, implemented in Swift behind the ports the Kotlin reducers name as effects. Memory Lane's `Workers/` has 21 of them: repository workers over its backend, push notifications, media storage, image downscaling, video transcoding, widget sync. The Android counterparts of these parts are the builders, `MainNavHost.kt` and `workers/` in the Android app module. ## Where do the theme and the controls live? * **`Libraries/Theming/`** holds the app's theme. Its semantic colors, fonts and gradients are generated by `duet design-tokens` from `parity/design-tokens.yaml`, the file the Android theme is generated from too, so both apps read the same token names. A hand edit to a generated file fails `duet design-tokens --check`. * **`Libraries/ThemedControls/`** holds the shared controls built on the theme. The scaffold emits `AppButton`, the one action-button style every screen uses, with primary, secondary and destructive roles. Memory Lane keeps its button, `MLButton`, in the Theming package. [Tutorial 7](/tutorials/duet-07-theming) builds the tokens and the generated code. ## What is in the app target? `src-ios/App//` holds what only the app's entry objects can own: * **`SceneDelegate`** builds the root with `RootBuilder(dependency: SceneComponent()).build()`, puts `RootScreen` in a `UIHostingController` inside the theme's scope, calls `activate()`, and calls `teardown()` when the scene disconnects. In Memory Lane it also forwards every opened URL to the app-services registry the root returned. * **`SceneComponent`** is the root level's Dependency: the objects only the scene can supply. As emitted it is empty. * **`AppDelegate`** carries process-level duties; in Memory Lane, the launch services and the push-notification device token. The Xcode project is generated: `xcodegen.yml` is the source, and the `.xcodeproj` is a build product. The spec gives the app scheme two build pre-actions, which run before Xcode plans the build: 1. **Increment `BUILD_NUMBER`**, for builds other than Debug. 2. **Assemble the Kotlin core**: `scripts/assemble_kit.sh debug` for a Debug build and `release` otherwise, so the build links the framework that contains the latest Kotlin edit. The scaffold emits Debug and Release configurations. Memory Lane builds with three, Debug, AdHoc and AppStore, each with its own file in `xcconfig/` over a shared `Base.xcconfig`, and adds a WidgetKit extension, `Widgets`, that reads what the app writes through the `WidgetShared` package's App Group storage. Side by side: Memory Lane's registration screen in SwiftUI on an iPhone — hand-drawn clouds, a portrait illustration, Continue with Apple and Continue with Google buttons — and in Jetpack Compose on a Pixel 8 emulator — the same serif tagline with a single Continue with Google button ## How do you build it? Open the generated project in Xcode, or let Modaal build and run it. Every build through the app scheme runs the assemble pre-action first. The first assemble on a machine downloads the Kotlin/Native toolchain and takes several minutes; later ones take seconds. A fresh clone needs one assemble before Xcode opens the project, because Swift package resolution reads the binary target before any pre-action runs: ```sh theme={null} scripts/assemble_kit.sh debug (cd src-ios/App && xcodegen generate --spec xcodegen.yml) open src-ios/App/.xcodeproj ``` Build simulator targets with `ARCHS=arm64` on the `xcodebuild` command line: the framework ships arm64 slices only, and a generic simulator build also compiles x86\_64. A concrete simulator destination is arm64 already. ## What does day one look like? On the day the scaffold runs, the same shape is there at minimal size: * `Kit` with the binary target and `Bridge`; * `Main` with `RootComposition.swift` (the root's Dependency, Component and Builder), `RootScreen.swift`, the analytics and ingress workers, and a test target; * `Theming` and `ThemedControls`; * the app target with its three entry files and the scheme's pre-actions. Each feature the scaffold adds is one `Shell` target in the Kit package, with its spec target, and one `Features/Mount.swift` in the Main package. The Kotlin side of the same feature is a module under `src-kmp/subtrees/`, and its recordings are under `parity/fixtures/`. ## Common questions A Swift package builds and tests without the app. `swift test` in the Kit package runs every shell's spec against the linked Kotlin core, and the Main package has its own test target for the composition root. The app target stays three files; the shells, the composition root and the workers are in packages with test targets. In the Kotlin modules under `src-kmp/subtrees/`, compiled into the XCFramework. The Swift side sends actions, renders state and performs effects through workers; every decision a recording pins is made in a Kotlin reducer. [Inside a Duet Android app](/articles/duet-android-app-anatomy#where-does-feature-logic-live) shows a feature module. Yes. The XCFramework links the Kotlin/Native runtime, including its collector, into the app executable. [Kotlin/Native GC and Swift ARC](/articles/kotlin-native-gc-swift-arc) measures its pauses and states the rules for Swift code that holds Kotlin objects. Edit `src-ios/App/xcodegen.yml` and regenerate. The project file is generated from it, so an edit made in Xcode's project editor is lost at the next `xcodegen generate`. On the Swift flavor the feature logic is Swift: each feature is a Swift package under `src-ios/Subtrees/`, with no binary target and no Kotlin/Native runtime. [The Duet overview](/articles/duet#the-two-duet-templates) compares the two flavors. [Modaal](https://modaal.dev) scaffolds this tree for you — say you're building a **Production app** for **Android + iOS** in the sentence on the home screen. ## Sources and further reading * [Kotlin Multiplatform: build final native binaries](https://kotlinlang.org/docs/multiplatform-build-native-binaries.html): `binaries.framework` and `XCFramework`, the Gradle side of the Kit package's binary target * [Swift Package Manager: binary targets](https://developer.apple.com/documentation/xcode/distributing-binary-frameworks-as-swift-packages): how a package declares a prebuilt XCFramework * [XcodeGen](https://github.com/yonaskolb/XcodeGen): the project spec format `xcodegen.yml` uses * [WidgetKit](https://developer.apple.com/documentation/widgetkit): the extension type Memory Lane's widgets use * [The Duet framework on GitHub](https://github.com/modaal-agent/duet): `DuetShells` (`ViewShell`, `StoreHost`) and the toolchain ## Read next The same repository's Android side: feature modules in `commonMain`, a thin Compose app module, builders and workers. What the Kit package's framework links into the app, the collector's measured pauses, and who frees what. The Kit package built by hand: the framework, the bridge, the first shell and the iPhone app. Shell, Builder, Component, mount, worker and the other terms on this page. # Duet Twins: a Swift Core for iPhone, a Kotlin Core for Android Source: https://docs.modaal.dev/articles/duet-twins Coming to Duet: each app runs its own feature logic, Swift on iPhone and Kotlin on Android, and one set of recorded behavior checks both copies in CI. This architecture is in development; Modaal does not scaffold it yet, and this page describes what it will ship. A Duet Twins project keeps each feature's logic twice: as a Swift reducer the iPhone app runs, and as a Kotlin reducer the Android app runs. Both reducers replay the same recorded fixtures in CI. A feature whose two copies disagree fails the check on the side that differs, with the fixture bytes that side produced. ## How does it compare with the Kotlin Multiplatform flavor? | | Duet Twins | Kotlin Multiplatform flavor | | - | - | - | | What the iPhone app runs | Swift reducers | the Kotlin core, through a Kotlin/Native framework | | What the Android app runs | Kotlin reducers | the same Kotlin core | | What the two apps share | the recordings | the core and the recordings | | Kotlin/Native in the iPhone app | none | the runtime and its collector ([Kotlin/Native GC and Swift ARC](/articles/kotlin-native-gc-swift-arc)) | | iPhone build | Xcode only | Xcode plus the core's assemble step | | Reducers edited per behavior change | two, one per language | one | | Apple targets that Kotlin/Native does not compile for | supported | not available | ## What does keeping two copies cost? Every behavior change edits the Swift reducer and the Kotlin reducer, and both must replay the new recordings before CI passes. The Kotlin Multiplatform flavor writes the reducer once and renders it twice; [What does sharing the logic cost?](/articles/share-code-ios-android#what-does-sharing-the-logic-cost) gives Modaal's measured cost of a change shipped to both apps on that flavor. Choose Duet Twins when one of the reasons below outweighs the second edit. ## Who is it for? * Teams where an iPhone developer and an Android developer each own their platform's language, including the feature logic. * Apps that must not link the Kotlin/Native runtime into the iPhone app. * Apps with an Apple target that Kotlin/Native does not compile for. ## What will ship? * An iPhone-only Duet app on the Swift flavor can add a Kotlin core and an Android app. * An Android-only Duet app can add a Swift core and an iPhone app. * Per feature, the second copy is written against the recordings the first copy produced, so it has an executable specification before its first line. * A feature may exist on one platform only; the manifest records which. * A tutorial built on a two-core app ships with the release. This page gives no dates. ## What can you do today? * **Start an iPhone app on the Swift flavor.** Every feature it records becomes the specification a Kotlin copy will be checked against when the second core can be added. * **Pick the Kotlin Multiplatform flavor** for one shared implementation of the logic with native SwiftUI and Jetpack Compose interfaces. [The two Duet templates](/articles/duet#the-two-duet-templates) compares the two flavors you can create today. ## Common questions No. It is one repository with one manifest and one set of recordings. The iPhone app and the Android app each build from their own source tree in it, and CI checks both against the same recordings. No. The Kotlin feature modules are plain Kotlin/JVM modules that the Android app compiles, and the iPhone app links no Kotlin. That route is not planned for the first release. ## Read next The two Duet templates you can create today and how a feature gets built. The three ways to ship two apps, and what each can prove about behavior. What the Kotlin Multiplatform flavor links into the iPhone app. Twin, coexistence window, recording and the other terms on this page. # Duet vs Compose Multiplatform: Share Logic or Share UI? Source: https://docs.modaal.dev/articles/duet-vs-compose-multiplatform Both are Kotlin Multiplatform. Compose Multiplatform also shares the UI, drawn on a canvas on iOS; Duet keeps SwiftUI native and shares the logic only. Duet and Compose Multiplatform are the closest pair on this site's comparison pages: both are Kotlin Multiplatform, both compile shared Kotlin for iOS with no bundled runtime, and on Android both render with Jetpack Compose. They differ on exactly one decision — Compose Multiplatform shares the UI too, rendering Compose on iOS from the same code, while **Duet**, Modaal's cross-platform parity framework for native iOS and Android apps, keeps the iOS interface in SwiftUI and shares the logic only. Everything else on this page follows from that one decision. *Last reviewed: September 1, 2026. Compose Multiplatform and Kotlin claims were checked against JetBrains' official documentation on this date.* ## How do Duet and Compose Multiplatform differ? | | Compose Multiplatform | Duet | | - | - | - | | Who draws the screen on Android | Jetpack Compose — the same androidx toolkit in both stacks | Jetpack Compose | | Who draws the screen on iOS | Compose, rendered by the framework onto a Skia canvas | SwiftUI — Apple's own toolkit, authored natively | | Where feature logic lives | Once, in Kotlin `commonMain`, compiled per platform | Once, in Kotlin `commonMain`, compiled per platform | | What proves the two platforms agree | One implementation — UI and logic alike | Recorded fixtures replayed on both platforms; CI compares byte-for-byte | | Shared fraction | Logic and UI | Logic, navigation decisions, effects and the test corpus; the two view layers are per platform | | Customizing beyond the catalog on iOS | Material 3 drawn by the framework; a control that must be UIKit's own is embedded through the interop, built in Kotlin via Objective-C interop or in Swift for Swift-only APIs | Every control is SwiftUI's own; customization is ordinary SwiftUI code | | A native, perf-heavy island | Drawing-heavy screens stay shared (Compose `Canvas` on Skia); a camera, video or GPU island is `AndroidView` interop on Android and, on iOS, a `UIKitView` whose factory builds the UIKit view — in Kotlin through Objective-C interop, or in Swift for Swift-only APIs — hosted inside a Skia-drawn screen, with the pipeline's logic outside the shared Kotlin | Ordinary SwiftUI/UIKit and Compose/Android code, no bridge; the reducer decides, a worker runs the pipeline, and the seam is recorded | | Native code you write and compile for it | Usually Kotlin only: on Android the island is Kotlin in the same Gradle build; on iOS Kotlin/Native's Objective-C interop builds UIKit views from Kotlin, and Swift is needed only for Swift-only APIs, which Xcode compiles in the app target | Yes, by design: Swift compiled by Xcode, Kotlin compiled by Gradle | | SwiftUI and Compose in the island | Compose is the UI on Android; on iOS a SwiftUI view is wrapped in `UIHostingController` and passed to `UIKitViewController` — the docs say "you have to write them in Swift and pass them to a Kotlin function" | They are the interface: SwiftUI on iOS, Jetpack Compose on Android, by construction | On Android the two stacks converge fully: JetBrains' own documentation states that on Android a Compose Multiplatform app "simply runs on Jetpack Compose", and Duet's emitted Android app pins Jetpack Compose through the androidx BOM (2025.06.01 in the current catalog). There is no Compose Multiplatform dependency in a Duet project — the `org.jetbrains.compose` artifacts exist for the non-Android targets Duet does not render on. ## What does Compose Multiplatform's iOS rendering actually do? Compose Multiplatform has been [stable on iOS since 1.8](https://blog.jetbrains.com/kotlin/2025/05/compose-multiplatform-1-8-0-released-compose-multiplatform-for-ios-is-stable-and-production-ready/) (May 2025); current is 1.12.0 (August 2026). On iOS the framework draws components onto a canvas via Skia — the same rendering-ownership decision Flutter makes, but without a language runtime: the Kotlin is compiled by Kotlin/Native, so the quadrant cell is "framework draws, compiled logic." Two qualifiers keep this description current: * **Text input is going hybrid.** Since 1.11.0 an experimental text field is backed by a real native `UIView`, giving platform-native gestures, selection handles and context menus inside the canvas. * **Accessibility is bridged to the native services.** Compose semantics are [mapped to native iOS Accessibility Services](https://kotlinlang.org/docs/compose-ios-accessibility.html) — VoiceOver and Full Keyboard Access work, and `testTag` surfaces as `accessibilityIdentifier` for XCTest audits. The documented shape is re-implementation plus mapping: the framework maintains the bridge, and platform behaviors arrive through it rather than by default. A fully native interface inherits new OS accessibility features on the OS's schedule. Interop runs both directions — `ComposeUIViewController` embeds Compose screens in SwiftUI apps, and `UIKitViewController` embeds native views inside Compose — so mixed adoptions are a documented path. JetBrains' [integration guide](https://kotlinlang.org/docs/multiplatform/compose-swiftui-integration.html) names maps, camera and web views as the cases for a native view inside Compose, and the same interop is the customization path: the catalog is Material 3 on every platform, so a control that must look and behave like UIKit's own is a native view embedded through the interop — built in Kotlin through Kotlin/Native's Objective-C interop, or in Swift when the API is Swift-only — and anything outside the canvas — a home-screen widget, a share extension — is Swift regardless. The fit is UI-neutral business applications whose screens are the same everywhere; the more the iOS interface must differ from the Android one, the more of it is written natively with the framework in between. ## If the language is the same, what is the actual choice? Fidelity versus coverage, and what "agree" means: * **Compose Multiplatform** maximizes shared code: interface included, one implementation, agreement by construction. The iOS interface is Compose — Material by default, themable toward Cupertino idiom, but authored in the shared dialect and rendered by the framework. * **Duet** fixes the interface as each platform's own and narrows sharing to what a fixture can check: state, actions, effects and their ordering. The iOS app is SwiftUI a platform engineer (or Apple's own tooling) recognizes without caveats; the price is a [second view layer, written per feature](/articles/duet#what-duet-requires-of-you). Agreement is by verification — recorded fixtures replayed on both platforms, byte-compared in CI. Stated plainly, as a position and not a measurement: Modaal's preference for native fidelity is a product judgment about what mobile interfaces should be. Compose Multiplatform's bet — that one excellent interface implementation beats two idiomatic ones — is a coherent judgment in the other direction, and JetBrains' a11y and text-input work above is that bet being taken seriously. ## What if one screen must be native and fast? On Android nothing changes: Compose Multiplatform is Jetpack Compose there, and a camera preview, a `SurfaceView` or a vendor SDK view is the same [`AndroidView`](https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/views-in-compose) interop a Duet Android app would use. On iOS, drawing-heavy screens stay shared — Compose's `Canvas` renders through Skia in Kotlin — while an island that must be UIKit's own (the docs' own examples: `MKMapView`, a camera, `WKWebView`) is a [`UIKitView`](https://kotlinlang.org/docs/multiplatform/compose-uikit-integration.html) whose factory returns the native view, hosted inside a Skia-drawn screen. Capture and processing pipelines — AVFoundation on iOS, CameraX and MediaCodec on Android — are platform code behind `expect`/`actual` declarations or a Swift wrapper the Kotlin calls into. The drawbacks: the iOS island is UIKit code inside a framework-rendered screen, so that screen has two renderers and two ownership models, and the pipeline's logic sits outside the shared implementation, kept equal by review. On Android there is no drawback relative to Duet, because the toolkit is the same. **Do you write and compile native code for it?** On Android, no new language: the island is Kotlin in the same Gradle build. On iOS, usually not Swift either. Kotlin/Native's [Objective-C interop](https://kotlinlang.org/docs/native-objc-interop.html) lets Kotlin construct UIKit objects directly — the docs' own factory builds `MKMapView()` in Kotlin — and Xcode compiles the app target around the framework Kotlin/Native produces. Swift becomes necessary for Swift-only APIs, because "pure Swift modules are not yet supported" by the interop: a SwiftUI view such as `Map`, or a Swift-only vendor SDK, is written in Swift, exposed to Objective-C, and compiled by Xcode. **Can the island be SwiftUI or Compose?** Compose is the interface on Android already. On iOS a SwiftUI view is the documented case of the Swift exception above: the [SwiftUI integration guide](https://kotlinlang.org/docs/multiplatform/compose-swiftui-integration.html) wraps it in `UIHostingController`, hands the controller to `UIKitViewController`, and states that "you have to write them in Swift and pass them to a Kotlin function". The reverse direction, `ComposeUIViewController` inside a SwiftUI app, is documented on the same page. In a Duet app the island is ordinary platform code — AVFoundation and Metal in the SwiftUI app, CameraX and MediaCodec in the Compose app — with nothing between it and the OS. The [reducer](/articles/duet-glossary#reducer) decides when capture starts, which filter applies and what happens with the result; a [worker](/articles/duet-glossary#worker) runs the pipeline. What is recorded and byte-compared is that seam: the requests the reducer makes and the results that come back. The pipeline's internals are outside the verified surface, and the island is written twice, once per platform: Swift compiled by Xcode, Kotlin compiled by Gradle, by design. ## What does Compose Multiplatform do better? * **More shared code.** The interface is written once. Duet writes it twice by design. * **The iteration loop.** [Compose Hot Reload](https://blog.jetbrains.com/kotlin/2026/01/compose-multiplatform-1-10-0/) is stable and on by default since 1.10.0 (January 2026); 1.12.0 added an experimental MCP server so AI agents can drive reloads, take screenshots and inspect the semantic tree. * **Reach beyond the two phone platforms.** Desktop is stable and the web target is in beta from the same codebase; Duet targets iOS and Android only. * **One design system by default.** If the product wants the same branded look everywhere, sharing the interface is the shorter path to it. ## When should you pick which? Pick Compose Multiplatform for UI-neutral business applications — forms, lists, dashboards — when your team is Kotlin-first, a single branded interface across platforms (and possibly desktop and web) is a feature, and platform-idiomatic iOS is a nice-to-have rather than a requirement. Pick Duet when SwiftUI-native iOS is a requirement, when you want no cross-platform UI dependency in the iOS app, or when the domain needs behavior agreement to be a mechanical check — [the recorded corpus](/articles/testable-android-apps) is what turns "the apps agree" from a review judgment into a failing build. The two stacks also compose over time in one direction: because Duet's core is ordinary Kotlin Multiplatform, its skills, libraries and tooling transfer — kotlinx, Ktor, the current stable Kotlin (2.4.10, which is what the Duet scaffold emits). Kotlin's Swift Export, in alpha since Kotlin 2.4.0, is the platform's stated direction for the Swift-facing seam both stacks consume today through the Objective-C bridge. ## Common questions No. Duet's shared core is plain Kotlin Multiplatform — logic only, no UI framework in `commonMain`. Android renders with Jetpack Compose (androidx artifacts), iOS with SwiftUI. Compose Multiplatform is JetBrains' extension of Jetpack Compose to non-Android targets, and a Duet project has no dependency on it. Technically the interop exists (a Compose screen is embeddable in SwiftUI via ComposeUIViewController), but a Duet project's parity contract covers logic, and its iOS interface convention is SwiftUI. Mixing rendering models per screen re-creates the consistency problem the architecture is built to remove — if shared UI is what you want, Compose Multiplatform proper is the honest choice. Yes — a defect in shared Kotlin ships to both apps, in either stack. The difference is the safety net around change: Duet's recorded scenarios replay on both platforms in CI, so a behavior change (or regression) is visible as a fixture diff on the commit that caused it. Compose Multiplatform projects bring their own test strategy, typically shared unit tests in commonTest. Both stacks stand on the same runtime facts: Kotlin/Native compiles to machine code and manages memory with a concurrent mark-and-sweep collector over a shared heap — a small runtime, no VM or interpreter. Whatever holds for one stack's iOS core holds for the other's; it is not a differentiator between them. In a Duet iPhone app, Swift's ARC references to Kotlin objects are roots for the collector, and Swift objects the core holds are released after a collection; [Kotlin/Native GC and Swift ARC](/articles/kotlin-native-gc-swift-arc) measures the pauses and states the rules for the Swift side. Google officially supports Kotlin Multiplatform for sharing business logic between Android and iOS, and ships KMP artifacts for Room, DataStore, ViewModel and more — that support statement covers the shared-core layer both stacks use. Compose Multiplatform itself is developed by JetBrains, extending Google's Jetpack Compose beyond Android. Duet projects are scaffolded by [Modaal](https://modaal.dev) — [starting a Duet project](/articles/duet#starting-a-duet-project) walks through the two template cards. ## Sources and further reading * [Compose Multiplatform and Jetpack Compose](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-and-jetpack-compose.html) — what the framework is relative to androidx Compose * [Compose Multiplatform 1.8](https://blog.jetbrains.com/kotlin/2025/05/compose-multiplatform-1-8-0-released-compose-multiplatform-for-ios-is-stable-and-production-ready/), [1.10](https://blog.jetbrains.com/kotlin/2026/01/compose-multiplatform-1-10-0/) and [1.12](https://blog.jetbrains.com/kotlin/2026/08/compose-multiplatform-1-12-0/) release posts — iOS stable, Hot Reload, the MCP server * [iOS accessibility in Compose Multiplatform](https://kotlinlang.org/docs/compose-ios-accessibility.html) — the semantics-to-native mapping and its documented gaps * [Kotlin Multiplatform on developer.android.com](https://developer.android.com/kotlin/multiplatform) — Google's support statement and KMP Jetpack artifacts * [Kotlin releases](https://kotlinlang.org/docs/releases.html) — current stable and the Swift Export status * [Integration with the UIKit framework](https://kotlinlang.org/docs/multiplatform/compose-uikit-integration.html), [Integration with the SwiftUI framework](https://kotlinlang.org/docs/multiplatform/compose-swiftui-integration.html) and [Using Views in Compose](https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/views-in-compose) — the escape-to-native routes for a heavy screen on each platform * [Interoperability with Swift/Objective-C](https://kotlinlang.org/docs/native-objc-interop.html) — what Kotlin can call on iOS without Swift, and the Swift-only limit * [The Duet framework on GitHub](https://github.com/modaal-agent/duet) — both flavors, the recording toolchain and the versioned contracts ## Read next The full survey — where the share-the-UI and share-the-logic cells sit on the quadrant. How Duet keeps two native interfaces honest: the presentation contract and recorded divergence. The decision one level up: write twice, share everything, or share the logic. The recorded-scenario discipline that gives Duet its verification story, shown in code. # Duet vs Flutter: Native UI vs One Codebase Source: https://docs.modaal.dev/articles/duet-vs-flutter Duet and Flutter both ship iOS and Android from shared code. They differ on who draws the screen, what ships in the binary, and what proves the apps agree. Flutter ships one codebase whose framework draws every pixel on both platforms; Duet ships two native interfaces over one shared, compiled logic core. Pick Flutter to write everything — interface included — once, in Dart; pick **Duet**, Modaal's cross-platform parity framework for native iOS and Android apps, when the interface must be the platform's own and the thing you share is behavior. This page compares the two models on the same three questions the [Duet overview](/articles/duet#how-duet-compares-to-other-cross-platform-approaches) asks of every approach. *Last reviewed: September 1, 2026. Flutter version and capability claims were checked against Flutter's official documentation on this date.* ## How do Duet and Flutter differ? | | Flutter | Duet | | - | - | - | | Who draws the screen | Flutter's own renderer — the Impeller canvas — on both platforms; widgets belong to the framework | Each platform's native toolkit: SwiftUI on iOS, Jetpack Compose on Android | | Where feature logic lives | Once, in Dart, running on the Dart runtime the app ships | Once, in Kotlin, compiled into both apps as a library | | What proves the two platforms agree | One implementation — there is nothing to compare, except code behind platform channels | Recorded fixtures replayed on both platforms; CI compares byte-for-byte | | Languages in the project | Dart (plus per-platform code at the boundary) | Kotlin for logic, Swift and Kotlin for the two interfaces | | The interface toolkit | Flutter's widget catalog, Material and Cupertino | Apple's and Google's own — HIG on one side, Material 3 on the other | | Customizing beyond the catalog | Theming covers Flutter's own widgets; a control that must be the platform's own is a platform view with a Swift/Kotlin side, and anything outside the canvas is platform code | Every control is already the platform's own; customization is ordinary SwiftUI and Compose code | | A native, perf-heavy island | Drawing-heavy screens stay in Dart (painters, GLSL shaders); a camera, video or GPU island is a `Texture` or a platform view fed by Swift/Kotlin plugin code, with its logic outside the shared Dart | Ordinary SwiftUI/UIKit and Compose/Android code, no bridge; the reducer decides, a worker runs the pipeline, and the seam is recorded | | Native code you write and compile for it | Yes: Swift or Objective-C in the iOS host project and Kotlin or Java in the Android one, compiled by Xcode and Gradle inside the Flutter build; a pub.dev plugin spares the writing, not the compiling | Yes, by design: Swift compiled by Xcode, Kotlin compiled by Gradle | | SwiftUI and Compose in the island | Yes, wrapped: an iOS platform view returns a `UIView`, so SwiftUI enters through `UIHostingController`; an Android platform view returns a `View`, so Compose enters through `ComposeView`; a full-screen Jetpack Compose activity is also documented | They are the interface: SwiftUI on iOS, Jetpack Compose on Android, by construction | ## Who draws the screen? Flutter draws every pixel itself. Since Flutter 3.44 the [Impeller renderer](https://docs.flutter.dev/perf/impeller) is the only engine on iOS, the default on Android API 29+ and, as of Flutter 3.47 (August 2026), the default on desktop; only the web target still renders with Skia. The widgets are Flutter's own — the Material catalog, plus Cupertino widgets that imitate Apple's controls. That buys one interface implementation and pixel-identical screens on both platforms; it also means the app's look is the framework's, and a new OS design language or control appears in your app when the framework implements it, not when the OS ships. The catalog is broad, its theming is deep — one `ThemeData` sets colors and type for every Material widget in the app — and customization *inside* the canvas is cheap, because Flutter owns the pixels and a bespoke widget is Dart. Customization *toward the platform* is the expensive direction. The Cupertino widgets are re-implementations with the parameters the framework exposes; a control that must be the platform's own — a native map, a web view, a camera preview — is a [platform view](https://docs.flutter.dev/platform-integration/ios/platform-views), `UiKitView` on the Dart side and a `FlutterPlatformView` factory in Swift or Objective-C on the other, and Flutter's documentation states that platform views "come with performance trade-offs". Anything outside the canvas — a home-screen widget, a share extension — is platform code regardless. Per-platform variation therefore costs what a platform channel costs: the control is written per platform, plus the bridge. Duet renders nothing itself. The iOS app is SwiftUI, the Android app is Jetpack Compose, and each inherits its platform's controls, typography, accessibility behavior and OS-version look natively. The cost is symmetric: those two interface layers are [written twice](/articles/duet#what-duet-requires-of-you). ## What ships in the app binary? A Flutter release build ships the Dart runtime alongside your ahead-of-time-compiled Dart code. That runtime is well-engineered and the sizes are documented — Flutter's own FAQ measured a minimal release APK at about 4.3 MB (ARM32) and a minimal IPA download at about 10.9 MB, figures the FAQ dates to March 2021 — but it is a second language runtime in the binary, and every screen and every rule runs on it. Duet's shared core is Kotlin compiled to machine code — Kotlin/Native for the iOS app, JVM bytecode on Android's own ART like any Android library. There is no bundled language VM or interpreter; the compiled core adds about 3 MB to the Android app, measured on the shipped app Modaal's Duet pages quote. The interface layers are ordinary platform code with no framework runtime under them. ## What proves the two platforms agree? Flutter's answer is identity by construction: one implementation means there is nothing to diverge — until the code crosses a [platform channel](https://docs.flutter.dev/platform-integration/platform-channels). Camera, health data, background execution, a vendor SDK: that code is written per platform in Swift/Objective-C and Kotlin/Java (Pigeon generates the type-safe bridge), and behavior on the two sides of the channel is once again kept equal by review and QA rather than by construction. Duet's answer is identity by verification: each feature's behavior is recorded as fixture files, both apps replay them, and CI compares the replay byte-for-byte — in the adoption measurement, 6 of 6 recordings were byte-identical across the Swift/Kotlin boundary. The guarantee covers exactly what the fixtures cover: state, actions, requested effects and their ordering. The two interface layers sit outside it by design, which is the same boundary honesty Flutter's platform channels need — stated up front in both cases. ## What if one screen must be native and fast? Split the case in two. A screen Flutter can draw — a keyframe-animated illustration, a custom-rendered chart, a shader effect — stays in Dart: Flutter owns the canvas, and [custom fragment shaders](https://docs.flutter.dev/ui/design/graphics/fragment-shaders) (GLSL, supported by both Skia and Impeller) and custom painters are ordinary Flutter code. No escape, and the screen is shared. An island that must be the platform's — a camera preview, hardware-decoded video, a Metal or ARKit view, an audio pipeline — leaves Dart on day one. Flutter's two routes are the [`Texture`](https://api.flutter.dev/flutter/widgets/Texture-class.html) widget, "a rectangle upon which a backend texture is mapped", fed by "a plugin that integrates with host platform video player, camera, or OpenGL APIs", and a platform view, which the docs say "come with performance trade-offs". Either way the island is written in Swift and Kotlin behind a plugin, and its processing logic — capture settings, frame filters, encoding — sits outside the shared Dart (or reaches C through [`dart:ffi`](https://docs.flutter.dev/platform-integration/ios/c-interop)), kept equal across platforms by review rather than by construction. The drawback is two native implementations plus the channel that carries their parameters and results, inside a screen the framework otherwise owns. **Do you write and compile native code for it?** Yes, unless an existing plugin covers the island exactly. The platform side is Swift or Objective-C in the `ios/` host project and Kotlin or Java in `android/`, compiled by Xcode and Gradle as part of the Flutter build. A plugin from pub.dev spares you the writing, not the compiling: its native source is built into your app the same way. A C library reached through `dart:ffi` is one more thing to build or vendor per platform. **Can the island be SwiftUI or Compose?** Yes, wrapped. A platform view is a `UIView` on iOS and an `android.view.View` on Android, so a SwiftUI view arrives through [`UIHostingController`](https://developer.apple.com/documentation/swiftui/uihostingcontroller), "a UIKit view controller that manages a SwiftUI view hierarchy", and Compose through [`ComposeView`](https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/compose-in-views), which "is an Android `View`". Flutter's [Android platform-views page](https://docs.flutter.dev/platform-integration/android/platform-views) documents the compositing modes and their trade-offs, and a separate guide covers [launching a full-screen Jetpack Compose activity](https://docs.flutter.dev/platform-integration/android/compose-activity), where "you will only write Kotlin code". Either way the SwiftUI or Compose code is yours to write, and the host project compiles it. In a Duet app the island is ordinary platform code — AVFoundation and Metal in the SwiftUI app, CameraX and MediaCodec in the Compose app — with nothing between it and the OS. The [reducer](/articles/duet-glossary#reducer) decides when capture starts, which filter applies and what happens with the result; a [worker](/articles/duet-glossary#worker) runs the pipeline. What is recorded and byte-compared is that seam: the requests the reducer makes and the results that come back. The pipeline's internals are outside the verified surface, and the island is written twice, once per platform: Swift compiled by Xcode, Kotlin compiled by Gradle, by design. ## What does Flutter do better? * **One implementation of everything.** Interface included. A screen is built once, looks the same on both platforms, and there is no second view layer to write or review. * **The iteration loop.** Stateful hot reload is mature and covers every target — including the web, where hot reload has been stable since Flutter 3.35 (August 2025). Duet's loop is compile-and-run per platform, plus a recording step when behavior changes. * **Package ecosystem.** pub.dev lists roughly 77,000 packages (September 2026), many wrapping both platforms behind one Dart API. * **Reach beyond the two phone platforms.** Stable desktop targets (with Canonical named lead maintainer of Flutter Desktop in May 2026) and a web target from the same codebase. Duet targets iOS and Android only. * **Add-to-app.** Embedding a Flutter module into an existing native app is an officially supported, documented path. ## When should you pick which? Pick Flutter for UI-neutral business applications — forms, lists, dashboards, internal tools, MVPs — whose screens look the same on both platforms and need little per-platform customization, and when one team writing Dart end-to-end outweighs native fidelity. The fit weakens as the interface must vary by platform: each control that has to be the platform's own is a platform view with a Swift and a Kotlin side, so a product that keeps asking for them pays for two native interfaces and the framework. Pick Duet when your users expect each platform's own behavior, when the interface must track the OS (new controls, accessibility features, design-language shifts) on release day, or when "do both apps do the same thing?" needs a mechanical answer because the domain is rules-dense — money, health, data. One framing note, so this page implies no benchmark that never ran: Modaal builds native-first — both apps use their platform's own toolkit — and that constraint was fixed before any framework evaluation. Flutter was ruled out by the constraint, not by a bake-off, and this page compares the models on their documented properties. ## Common questions Cupertino widgets imitate Apple's controls on Flutter's canvas; they are re-implementations, not the platform's controls. They track iOS closely, but new OS controls and behaviors arrive when the framework implements them, and system behaviors attached to real UIKit/SwiftUI views come from the imitation, not the OS. Whether that difference matters is a product judgment — this page's position is stated above. Dart is a sound, garbage-collected language with excellent tooling, and treating it as a defect would be wrong. The trade is staffing and reach: Dart expertise is mostly Flutter expertise, while Duet's languages are the platforms' own — Kotlin and Swift — which iOS and Android engineers already write. Add-to-app embeds Flutter screens into a native app, but the shared code still runs on the shipped Dart runtime, and driving two fully native interfaces from a Dart core is not the framework's design. The share-the-logic route with a compiled core is Kotlin Multiplatform or Rust territory — the survey page maps the options. No. The interface layers use each platform's own preview tooling (SwiftUI previews, Compose previews), and logic changes re-run the recorded scenarios — a 1–3 second warm suite at four-feature scale in the field measurement. It is a different loop: slower to see pixels, faster to prove behavior. Flutter gives an agent one language and a huge training corpus. Duet gives an agent machine-checkable gates: a recorded scenario must pass before a screen can wire to logic, and CI replays the corpus on every push, so a wrong edit fails mechanically instead of compounding. The iOS architecture comparison scores this axis across architectures. Duet projects are scaffolded by [Modaal](https://modaal.dev) — [starting a Duet project](/articles/duet#starting-a-duet-project) walks through the two template cards. ## Sources and further reading * [Flutter documentation](https://docs.flutter.dev) and the [Flutter FAQ](https://docs.flutter.dev/resources/faq) — rendering model, app-size figures, governance * [Impeller rendering engine](https://docs.flutter.dev/perf/impeller) — engine status per platform * [Platform channels](https://docs.flutter.dev/platform-integration/platform-channels) and [Add-to-app](https://docs.flutter.dev/add-to-app) — the native boundary and embedding paths * [Hot reload](https://docs.flutter.dev/tools/hot-reload) — scope and targets * [Fragment shaders](https://docs.flutter.dev/ui/design/graphics/fragment-shaders), the [`Texture` widget](https://api.flutter.dev/flutter/widgets/Texture-class.html), [platform views](https://docs.flutter.dev/platform-integration/ios/platform-views) and [C interop](https://docs.flutter.dev/platform-integration/ios/c-interop) — the in-canvas and escape-to-native routes for a heavy screen * [Android platform views](https://docs.flutter.dev/platform-integration/android/platform-views) and [launching a Jetpack Compose activity](https://docs.flutter.dev/platform-integration/android/compose-activity); Apple's [`UIHostingController`](https://developer.apple.com/documentation/swiftui/uihostingcontroller) and Android's [`ComposeView`](https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/compose-in-views) — how SwiftUI and Compose enter a platform view * [The Duet framework on GitHub](https://github.com/modaal-agent/duet) — both flavors, the recording toolchain and the versioned contracts ## Read next The full survey: every mainstream framework on the same three questions, plus the quadrant this pair sits in. The nearest relative on the canvas side: same language decision as Duet, opposite UI decision. The decision one level up: write twice, share everything, or share the logic. What Duet is, the combinations that scaffold it, and the six-step loop every feature follows. # Duet vs Ionic Capacitor: Native UI or a Web View? Source: https://docs.modaal.dev/articles/duet-vs-ionic-capacitor Capacitor runs one web app in the system web view; Duet shares a compiled Kotlin core under SwiftUI and Compose. A lab app measures what a native look costs. Capacitor packages one web app — HTML, CSS and JavaScript — as an iOS app and an Android app, and renders it in the operating system's web view. Ionic Framework is the component library usually paired with it, supplying tab bars, toolbars and lists as web components styled after each platform. **Duet**, Modaal's cross-platform parity framework for native iOS and Android apps, takes the opposite position on both questions the [framework survey](/articles/best-cross-platform-framework#how-should-you-compare-cross-platform-frameworks) asks: each platform's toolkit draws the screen, and the shared logic is Kotlin compiled into both apps. This page compares the two on those questions and on a third — what proves the two apps agree — and uses a public [lab repository](https://github.com/modaal-agent/lab-replicate-parro-crossplatform) that built one four-tab app in Ionic 9 and Capacitor 8 to show what the differences cost in lines of code, app size, build time and agent time. *Last reviewed: September 18, 2026. Capacitor and Ionic claims were checked against their official documentation on this date. The measurements come from the lab repository's builds of September 13–14, 2026; its Capacitor app was built and measured on iOS only.* ## How do Duet and Ionic Capacitor differ? | | Ionic Capacitor | Duet | | - | - | - | | Who draws the screen | The system web view — `WKWebView` on iOS, the Chromium-based Android WebView on Android — rendering Ionic's components as HTML and CSS | The platform — SwiftUI on iOS, Jetpack Compose on Android, written per platform | | Where feature logic lives | Once, in JavaScript or TypeScript, run by the web view's JavaScript engine: WebKit's on iOS, Chromium's on Android | Once, in Kotlin, compiled into both apps as a library | | What ships in the binary | The web bundle, Capacitor's native runtime and each plugin's native code; the JavaScript engine belongs to the operating system | The compiled Kotlin core and the two native view layers; no language VM or interpreter | | App size in the lab (iOS simulator Release `.app`) | 7,920–11,604 KiB: 5.5–8.5× a SwiftUI build of the same screens (1,308–1,444 KiB) | Not measured in the lab; on Android the compiled core adds about 3 MB | | Clean Release build in the lab (iOS) | 7.3–8.9 s, 3.1–4.6 s of it before Xcode, against 4.8 s for the SwiftUI build in the same run | Not measured in the lab; the Kotlin Multiplatform flavor adds a Gradle step before Xcode | | What proves the two platforms agree | One implementation, rendered by two browser engines; plugin code in Swift and Kotlin sits outside it | Recorded fixtures replayed on both platforms; CI compares byte-for-byte | | The interface toolkit | Ionic's components, with an `ios` and an `md` mode that restyle the same HTML toward each platform | Apple's and Google's own controls — HIG on iOS, Material 3 on Android | | Customizing beyond the catalog | CSS on the page; a control that must be UIKit's or Android's own comes from a plugin, as a native view placed over, around or under the web view | Every control is the platform's own; customization is ordinary SwiftUI and Compose code | | A native, perf-heavy island | A Capacitor plugin in Swift and Kotlin; its native UI is presented over the web view, or drawn under it and aligned with an HTML element, as the Google Maps plugin does on Android | Ordinary SwiftUI/UIKit and Compose/Android code, no bridge; the reducer decides, a worker runs the pipeline, and the seam is recorded | | Native code you write and compile for it | Yes: Swift or Objective-C and Kotlin or Java in the generated `ios/` and `android/` projects, compiled by Xcode and Gradle; an npm plugin spares the writing, not the compiling | Yes, by design: Swift compiled by Xcode, Kotlin compiled by Gradle | | SwiftUI and Compose in the island | Only as native views a plugin places over, around or under the page, written in Swift or Kotlin; CSS does not lay them out | They are the interface: SwiftUI on iOS, Jetpack Compose on Android, by construction | | Dependency manager on iOS | Swift Package Manager for new projects since Capacitor 8; in such a project, a plugin that ships only a CocoaPods podspec is left out of the build | Swift Package Manager and Gradle — the platforms' defaults | ## Who draws the screen? A Capacitor app is a native shell around one web view. On iOS the shell's view controller hosts a `WKWebView`, which renders with the WebKit of the installed iOS version. On Android, [Capacitor's documentation](https://capacitorjs.com/docs/android) states that Chrome provides the WebView on Android 7–9 and the Android System WebView does on Android 10 and later, and it requires a WebView with Chrome version 60 or later; that WebView has been [updatable from Google Play](https://developer.android.com/about/versions/lollipop) separately from the OS since Android 5.0. Capacitor 8 supports iOS 15 and Android 7.0 (API 24) and later. It loads the app's bundled web assets into the web view and connects JavaScript to native code through plugins. Every screen, list and control the user sees is an HTML element: Ionic's `ion-tab-bar` and `ion-toolbar` are elements in the page, laid out by CSS, and neither is a `UITabBar` or a `UINavigationBar`. Screen readers reach the page through the web engine's accessibility support; Ionic's [accessibility guide](https://ionic.io/docs/accessibility/aria) states that its components "provide accessibility features through ARIA". The lab did not test VoiceOver. Ionic's components choose a [mode](https://ionicframework.com/docs/theming/platform-styles) per platform: `ios` on iOS devices, and `md` (Material Design) on Android and every other platform. Ionic's documentation states that styling the components to each platform's guidelines "allows the application to be written once but look and feel native to the user". Each mode is a stylesheet that re-implements the platform's look in CSS, so a new platform design reaches an Ionic app when Ionic or a community theme writes it. iOS 26's Liquid Glass is the current example. A SwiftUI `TabView` built with the iOS 26 SDK draws the floating glass tab bar with no styling code; the lab's SwiftUI build, on the iOS 26.5 SDK, showed it from a plain `TabView`. Ionic's [8.8 announcement](https://ionic.io/blog/announcing-ionic-framework-8-8) (March 6, 2026) states: "While we are not planning to ship an official Liquid Glass theme right now, the goal is to provide the underlying architecture that enables developers to implement these types of design systems themselves". In `@ionic/core` 9.0.3, `grep -ril "liquid\|glass"` over `dist/collection` prints nothing. The lab used the community theme `@rdlabo/ionic-theme-ios26`, which draws glass with CSS `backdrop-filter` blur and saturation. The lab found two parts of the iOS look that web content cannot draw, on the iOS 26.5 simulator: * **Glass refraction.** An SVG displacement filter renders under `filter` and does not render under `backdrop-filter` in `WKWebView`, although `CSS.supports` reports the value as supported; [WebKit bug 245510](https://bugs.webkit.org/show_bug.cgi?id=245510), which tracks it, had status NEW when the lab read it on September 13, 2026. The web build's glass has blur and saturation and no lens distortion. * **SF Symbols.** The page cannot draw Apple's symbols by name, so the lab substituted Ionicons: `people` for `person.3`, `open-outline` for `arrow.up.forward`. Duet renders nothing itself. The iOS app is SwiftUI and the Android app is Jetpack Compose. In the Duet tutorial app, Foyer, the iOS tab bar is a SwiftUI [`TabView`](https://github.com/modaal-agent/duet-tutorials/blob/main/tutorial6-complete/src-ios/Libraries/FoyerKit/Sources/MainShell/MainView.swift) and the Android one is a Material 3 [`NavigationBar`](https://github.com/modaal-agent/duet-tutorials/blob/main/tutorial6-complete/src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/MainScreen.kt); both render the same `activeTab` value from the shared state, and a tap on either sends the same `TabSelected` action. The cost is that the two view layers are [written twice](/articles/duet#what-duet-requires-of-you). ## What does a native look cost in a Capacitor app? The lab repository builds four tabs of a school-messaging app — Home, Calendar, Chat with a conversation screen, and Settings — with Ionic 9.0.3, React 19, Capacitor 8.5.2 and the same placeholder data throughout. Its `ionic-capacitor/` directory carries two styling variants built from one codebase: * **`stock`** uses Ionic's iOS mode as the tabs starter ships it, with one accent color and no CSS written to match anything. * **`matched`** adds the Liquid Glass theme and its own CSS, styled to match a SwiftUI build of the same screens in the same repository. That SwiftUI build is a plain SwiftUI app — no shared core, no recordings, no Android half — so this page uses it as the reference `matched` was measured against and as the native baseline for app size and build time, and quotes no Duet figures from it. Claude Code wrote both variants at the repository owner's direction, and the repository's [spec](https://github.com/modaal-agent/lab-replicate-parro-crossplatform/blob/main/specs/001-four-tab-shells/spec.md) records every count below (§14–§16, §20): | Measure | `stock` | added for `matched` | | - | - | - | | Hand-written code | About 1,810 lines in `src/` for the screens, the data and the behavior, of which 275 lines of CSS in 7 files | About 1,000 more lines: 754 in `src/styles/matched/` and `variant.ts`, and 240 lines added (80 removed) across 9 shared TSX files | | The stylesheet | — | `index.css`, 725 lines: 174 pixel values, each read from the SwiftUI build's source, screenshots or accessibility frames; 9 `!important` declarations | | npm packages added | — | 1, `@rdlabo/ionic-theme-ios26` 9.2.0 | | Web bundle (`dist/`) | 3,068 KiB, 19 files | 6,252 KiB, 205 files: the theme's 20 imports from `@ionic/core` pull in 104 lazy-loading chunks, 2,108 KiB | | Agent work | 141 tool calls, 47 minutes of active time | 202 tool calls, 65 minutes, 8 screenshot rounds | | Differences left against the SwiftUI reference | Not compared: §14.5 lists `stock`'s differences from the original app's screenshots, most of them Ionic defaults | 5: SF Symbols, glass refraction, the Calendar title's transition, the time format, a 4 pt scroll offset | Three findings from those passes bear on the choice of stack: * **The look needed a native reference.** Every value in `matched`'s stylesheet was measured from the SwiftUI build. The spec does not estimate how long the same pass takes without one; the alternative sources it names are Apple's design resources and measurements of Apple's own apps. * **The look depends on framework internals.** `matched` styles five of Ionic's shadow parts and depends on Ionic's internal class names (`tab-bar-hidden`, `footer-toolbar-padding`, `header-collapse-condense`), and the theme's peer range ends below `@ionic/core` 10. An Ionic or theme upgrade that changes any of them needs a new screenshot round; `stock` does not. * **The look is per platform.** `matched` restyles Ionic's `ios` mode. The lab built no Android app; a Capacitor app matched to Material 3 on Android would need a second styling pass in `md` mode, which the lab did not measure. In a Duet app the corresponding work is the second view layer: each screen is written once in SwiftUI and once in Compose, from the platform's own components, and the shared state they render is recorded and verified. The iOS screens use SwiftUI's containers and controls — Foyer's tab bar is a `TabView` and its lists are `List`s — and those take the current iOS design when the app is built with the current SDK. ## What ships in the app binary? Capacitor ships no JavaScript engine: the app's code runs in the system web view's engine, so its version is the device's — the WebKit of the installed iOS, and the WebView Google Play has installed on Android. The binary carries the web bundle, Capacitor's frameworks and each plugin's native code. Simulator Release `.app` sizes from the lab, against the repository's SwiftUI build of the same screens (a plain SwiftUI app, not a Duet app): | Stage (spec section) | SwiftUI | Capacitor `stock` | Capacitor `matched` | | - | - | - | - | | Phone shell (§15.3) | 1,308 KiB | 7,920 KiB (6.1×) | 11,152 KiB (8.5×) | | Plus the iPad wide layout (§18.2, §19.3) | 1,444 KiB | 7,940 KiB (5.5×) | 11,176 KiB (7.7×) | | Plus a UIKit tab bar (§20.3) | — (a `TabView` from the first stage) | — | 11,604 KiB (8.0×) | Every Capacitor build carries 4,480 KiB of `Capacitor.framework` and `Cordova.framework`, the layer for [Cordova plugins](https://capacitorjs.com/docs/plugins/cordova) that the [Capacitor 9 roadmap](https://ionic.io/blog/the-road-to-capacitor-9) makes optional. The rest is the web bundle: 3,068 KiB in `stock`, about half of it a legacy copy of the JavaScript that the Ionic starter's `@vitejs/plugin-legacy` generates, and 6,252 KiB in `matched`, whose theme pulls in 2,108 KiB of `@ionic/core` chunks. The SwiftUI build's code is its 1.2 MB binary. Duet's shared core is compiled Kotlin — Kotlin/Native on iOS, JVM bytecode on Android's ART — and adds about 3 MB to the Android app, measured on the shipped app Modaal's Duet pages quote. A Duet iOS app in the Kotlin Multiplatform flavor is a SwiftUI app plus the core's Kotlin/Native framework, for which this site publishes no size. ## How long does a clean build take? A Capacitor iOS build runs the web build and `npx cap sync ios`, then Xcode, which links Capacitor's prebuilt XCFrameworks and compiles plugin sources. The SwiftUI build is one Xcode step. Clean Release builds at the phone-shell stage, one run of each on September 14, 2026 (spec §15.3): | | SwiftUI | Capacitor `stock` | Capacitor `matched` | | - | - | - | - | | Web build and `npx cap sync ios` | — | 3.07 s | 4.59 s | | Clean Release `xcodebuild` | 4.84 s | 4.19 s | 4.27 s | | Total | 4.84 s | 7.26 s | 8.86 s | These are single runs on one machine, at a load average of 2.86 to 7.08. In a separate before-and-after run, adding the UIKit tab bar plugin took `matched`'s clean Release `xcodebuild` from 4.81 s to 9.75 s; that build compiles the plugin's 3,170 lines of Swift from source. A Duet iOS build in the Kotlin Multiplatform flavor adds a Gradle step for the Kotlin/Native framework; the lab did not time one. ## What proves the two platforms agree? Capacitor's answer is one implementation. Logic in the JavaScript bundle is shared by construction; two different engines render and run it — WebKit on iOS, Chromium on Android — so layout and CSS differences between the platforms are found by testing on both, as in cross-browser web work. Plugin code in Swift and Kotlin is written per platform and kept equal by review and QA. The lab's Capacitor build ran on iOS only, so it measured nothing on this question. Duet's answer is verification: each feature's behavior is recorded as fixture files, both apps replay them, and CI compares the replay byte-for-byte — in the adoption measurement, 6 of 6 recordings were byte-identical across the Swift/Kotlin boundary. The guarantee covers what the fixtures cover: state, actions, requested effects and their ordering. The two view layers sit outside it, and [Handling platform-specific UI](/articles/cross-platform-ui-parity) describes how their deliberate differences are recorded. ## What if part of the screen must be native? Capacitor's route to native is a plugin: Swift or Objective-C in the [iOS project](https://capacitorjs.com/docs/ios/custom-code) and Kotlin or Java in the [Android one](https://capacitorjs.com/docs/android/custom-code), called from JavaScript. The documented ways to show native UI place it outside the page's layout: * **Over the web view.** On iOS the bridge's view controller "can be used to present native views over the app" ([iOS bridge API](https://capacitorjs.com/docs/core-apis/ios)); on Android a plugin starts a native Activity. * **Under or into the web view, aligned with an HTML element.** The official [Google Maps plugin](https://capacitorjs.com/docs/apis/google-maps) renders the map "beneath the entire webview" on Android, which requires every layer of the page above it to be transparent, and "directly into the webview" on iOS. The plugin moves the native view to follow an HTML element's position as the page scrolls. * **Around the web view.** A plugin can put native bars around the web view, which is what the lab did. In each case the native view is not a DOM element: CSS does not lay it out, and the page's own components cannot contain it. The lab measured one such case: replacing the CSS tab bar with UIKit's (§20 of the spec). * **The first plugin tried did not build.** `stay-liquid` ships a CocoaPods podspec and no `Package.swift`. The iOS project Capacitor 8 generates uses Swift Package Manager, and `npx cap sync ios` printed "stay-liquid does not have a Package.swift" and left the plugin's Swift out of the build. A plugin in that state needs its maintainer to add a Swift package; the Capacitor 9 roadmap notes that CocoaPods trunk becomes read-only on December 2, 2026. * **The second plugin draws the bar by moving the web view.** `@capgo/capacitor-native-navigation` 8.3.1 creates a `UITabBarController`, moves the Capacitor web view into the selected tab's content controller, and sends a `tabSelect` event to JavaScript. The app's router still owns the routes: the lab's handler clicks the hidden `ion-tab-button` of the same tab. * **The cost.** 154 hand-written lines and no Swift: 91 lines of TypeScript connecting the routes to the bar, 41 lines of CSS, 22 for the accent color. The build compiles 3,170 lines of the plugin's Swift from `node_modules`, and the app binary grew from 154,800 to 567,808 bytes. The work took 142 agent tool calls in 33 minutes. * **The side effects.** Inside the tab controller the web view's bottom safe-area inset changed from 34 pt to 83 pt. The theme computed positions from the 34 pt value, so the chat's "+" button sat 49 pt too high until 41 lines of CSS replaced that arithmetic. The keyboard's background turned from dark to light grey, because the plugin's container takes the web view's background color. On iPad the bar ignored the plugin's tint and needed an `AccentColor` in the app target. * **What stays HTML.** The tab bar is UIKit's; the navigation bars, lists and buttons on each page are still Ionic's elements. **Do you write and compile native code for it?** Yes, unless an npm plugin covers the case exactly. A plugin from npm spares the writing, not the compiling: its Swift and Kotlin are built into the app by Xcode and Gradle, as the 3,170 lines above were. **Can the island be SwiftUI or Compose?** Only as a native view a plugin places over, around or under the page, written in Swift or Kotlin — a SwiftUI view through `UIHostingController`, a Compose view in a native Activity or view. The page's HTML cannot contain one. In a Duet app the island is ordinary platform code — AVFoundation and Metal in the SwiftUI app, CameraX and MediaCodec in the Compose app — with nothing between it and the OS. The [reducer](/articles/duet-glossary#reducer) decides when capture starts, which filter applies and what happens with the result; a [worker](/articles/duet-glossary#worker) runs the pipeline. What is recorded and byte-compared is that seam: the requests the reducer makes and the results that come back. The pipeline's internals are outside the verified surface, and the island is written twice, once per platform: Swift compiled by Xcode, Kotlin compiled by Gradle, by design. A tab bar in a Duet app needs no plugin: it is the platform's own control, bound to shared state, as the Foyer example above shows. ## What happens when the platform changes its layout rules? Apple [announced iPhone Duo](https://www.apple.com/newsroom/2026/09/apple-unveils-iphone-duo/) on September 9, 2026, available October 23 with iOS 27.1. On it, "app navigation and controls now appear on the side to maximize vertical space for content", and Split View puts two apps side by side. Per Apple's guidance, the system moves bars to the side only when they come from its own containers: * [Preparing your app for iPhone Duo](https://developer.apple.com/documentation/technologyoverviews/preparing-your-app-for-iphone-duo) says to "prefer system-provided layouts and containers, such as split views, tab bars, arrangement views, and navigation stacks", and, if bars do not present vertically, to check that the app uses "the bar support that navigation containers, such as a tab view or navigation stack, provide". * The tech talk [Raise the bar with iPhone Duo](https://developer.apple.com/videos/play/tech-talks/111462/) says content from custom bars built from `UIToolbar`, `UINavigationBar` or `UITabBar` "won't be considered", and names `UINavigationController` and `UITabBarController` as the containers to use. * The tech talk [Prepare your app for iPhone Duo](https://developer.apple.com/videos/play/tech-talks/111461/) gives the inner display regular horizontal and vertical size classes, and warns that safe areas "are often asymmetric". An HTML tab bar and HTML navigation bars are none of those containers. The lab's September 14 review (in its [DISCOVERY.md](https://github.com/modaal-agent/lab-replicate-parro-crossplatform/blob/main/DISCOVERY.md)) found no fold, segment or reserved-region API in the iOS 27.0 SDK's WebKit headers, so the web view receives a resized viewport and its safe-area insets and nothing about the fold. It also found a layout the web build would get wrong. The Capacitor build switches to two columns at `(min-width: 672px)`, the width at which SwiftUI's size class changed in the lab's iPad windows. The inner display in portrait is 669 points wide, taking the 2,007 × 2,853 px screenshot size App Store Connect lists for it at 3x, so the web build would show its phone layout on a display Apple gives regular width. Unfolding from the outer display (466 points wide) to the inner display in landscape (951 points) crosses the same breakpoint. In the lab's iPad windows each crossing of 672 points mounted a new router outlet, and state held in page components started again: keeping a typed message draft across the change took a separate store, `Drafts.tsx`, 46 lines. The lab's recommendations for a Capacitor app, in the same entry: pass the size class from native code to the web view and choose the layout from it; let a `UITabBarController` own the tab bar, as the plugin above does; handle each safe-area inset per side; and keep navigation state in the URL or a store rather than in components that remount. None of it could be run on iPhone Duo: Xcode 27.1 beta, which carries the iPhone Duo simulator, is listed as "coming later this month" on Apple's [iPhone Duo page](https://developer.apple.com/iphone-duo/) as of September 18, 2026. No Duet app has been run on it either, for the same reason. A Duet app's iOS screens are SwiftUI, so they can use the containers Apple's guidance names — Foyer's tab bar is a `TabView` — and a rebuild with the iOS 27.1 SDK is the documented step for the side placement. Feature state lives in stores outside the view tree, as the [Duet overview's lifecycle answer](/articles/duet#common-questions) describes, so a relayout on fold or unfold redraws the views over state the stores still hold. ## What does Ionic Capacitor do better? * **One interface implementation, plus the web.** The same HTML, CSS and TypeScript run in the iOS app, the Android app and a browser, and Capacitor's documentation states "first-class support for [Progressive Web Apps](https://capacitorjs.com/docs/web/progressive-web-apps)", with many plugins offering the same API on the web. The lab's code uses the native tab bar only on iOS and keeps Ionic's HTML tab bar in a browser (`nativeTabBar.ts`). Duet targets iOS and Android only, with two view layers. * **Web skills and the npm ecosystem.** A team that builds web apps can ship mobile apps without learning Swift or Kotlin until it needs a plugin, and an existing web app can be packaged rather than rewritten. * **The iteration loop.** The web app runs in a desktop browser under the web tooling's dev server, and [live reload](https://capacitorjs.com/docs/guides/live-reload) (`npx cap run ios --live-reload`) loads it from that server into the web view on a device on the same Wi-Fi network, reloading on each change. The lab's clean web builds took 2.7 to 5.2 seconds, and `npx cap sync ios` under one second. * **A usable default look.** `stock` reached an iOS-styled four-tab app with 275 lines of the app's own CSS; everything else came from Ionic's iOS mode. * **Updates without a store release.** The interface and logic ship as web assets, which can be replaced over the air within Apple's rules on downloaded interpreted code, quoted in the over-the-air answer below. A compiled core cannot be replaced this way, and Duet ships every change through the App Store and Google Play. ## When should you pick which? Pick Capacitor when you have a web app or a web team, when the product also ships on the web, when the interface can follow Ionic's platform modes or one brand design system shared by every platform, and when the native features you need are covered by maintained plugins. Pick Duet when users expect each platform's current controls — a Liquid Glass tab bar, bars that move to the side on iPhone Duo — without re-implementing them in CSS, or when the domain is rules-dense and "do both apps do the same thing?" needs a mechanical answer. Duet targets iOS and Android only; a product whose web version must run the same code as its mobile apps needs a web stack such as Capacitor. The framing note this site puts on every comparison: Modaal builds native-first, with both interfaces in the platforms' own toolkits and no shipped runtime, and that constraint was fixed before any framework evaluation. Capacitor was ruled out by the constraint. The lab measured the effort to make a Capacitor app look like a SwiftUI app on iOS; it measured no frame rates or startup times, and this page quotes none. ## Common questions No. Capacitor packages any web app — React, Vue, Angular or plain JavaScript — and Ionic Framework is one component library you can put in it. The lab used both, because Ionic supplies the tab bar, toolbars and iOS-styled lists that a native look starts from. As native views outside the page's layout, yes: a plugin can present a native screen, draw a native view under or into the web view as the Google Maps plugin does, or, like `@capgo/capacitor-native-navigation`, put a `UITabBarController` or `UINavigationBar` around the web view. As page elements, no: every list, button and field that CSS lays out is HTML. In the lab, the native tab bar replaced one element of the shell; the navigation bars and every list stayed Ionic's. No. The app's code runs in the system web view's engine — WebKit on iOS, the Chromium-based WebView on Android — so the binary carries no engine. The engine version is the device's: the WebKit of the installed iOS, and the WebView version Google Play has installed on Android, which Capacitor requires to be Chrome 60 or later. Web assets can be replaced without a store release. Apple's [Developer Program License Agreement](https://developer.apple.com/support/terms/apple-developer-program-license-agreement/) allows it in section 3.3.1(B): "Interpreted code may be downloaded to an Application but only so long as such code" does not change the app's primary purpose, does not bypass the OS's signing, sandbox or other security features, and does not create a store for other apps. Ionic's own service for this, Appflow, [stopped taking new customers](https://ionic.io/blog/important-announcement-the-future-of-ionics-commercial-products) on February 11, 2025, and existing users keep access through December 31, 2027; Ionic's migration article names Capawesome Cloud and Capgo for live updates. A Duet app's compiled core cannot be replaced this way: every change ships through the App Store and Google Play. There is no Capacitor-to-Duet converter, and this site does not claim one. A move to Duet means re-authoring each feature against recorded specs, one feature at a time, with a SwiftUI and a Compose screen per feature. If the web app must keep running in browsers, it stays a web app beside the native ones. The lab is itself an agent measurement: Claude Code built both Capacitor variants in 343 tool calls. Matching the native look took techniques an agent uses when the system does not supply the values — temporary outlines on toolbars, accessibility frames read from both apps, hit-test probes, pixel comparisons. UI automation also sees less of a web view: `axe describe-ui` listed no web content in its tree dump and returned web elements only for queries at a point. Duet gives an agent machine-checkable gates: a screen cannot wire to logic without a passing recording, and CI replays the corpus on every push. Duet projects are scaffolded by [Modaal](https://modaal.dev) — [starting a Duet project](/articles/duet#starting-a-duet-project) walks through the two template cards. ## Sources and further reading * [Capacitor documentation](https://capacitorjs.com/docs), [Updating to Capacitor 8](https://capacitorjs.com/docs/updating/8-0), the [support policy](https://capacitorjs.com/docs/main/reference/support-policy) and [The road to Capacitor 9](https://ionic.io/blog/the-road-to-capacitor-9) — the runtime, minimum platforms, Swift Package Manager as the default, the Cordova layer * Capacitor's [Android](https://capacitorjs.com/docs/android) page, [custom native iOS code](https://capacitorjs.com/docs/ios/custom-code), [custom native Android code](https://capacitorjs.com/docs/android/custom-code), the [iOS bridge API](https://capacitorjs.com/docs/core-apis/ios) and the [Google Maps plugin](https://capacitorjs.com/docs/apis/google-maps) — the web views, and where a plugin's native UI goes * Capacitor's [live reload](https://capacitorjs.com/docs/guides/live-reload), [Progressive Web Apps](https://capacitorjs.com/docs/web/progressive-web-apps) and [Cordova plugins](https://capacitorjs.com/docs/plugins/cordova) pages * [Ionic Framework documentation](https://ionicframework.com/docs), [platform styles](https://ionicframework.com/docs/theming/platform-styles), the [accessibility guide](https://ionic.io/docs/accessibility/aria), the [Ionic Framework 9 announcement](https://ionic.io/blog/announcing-ionic-framework-9) and the [8.8 announcement](https://ionic.io/blog/announcing-ionic-framework-8-8) — platform modes, ARIA, and the statement on a Liquid Glass theme * [The future of Ionic's commercial products](https://ionic.io/blog/important-announcement-the-future-of-ionics-commercial-products) and Apple's [Developer Program License Agreement](https://developer.apple.com/support/terms/apple-developer-program-license-agreement/), section 3.3.1(B) — live updates * [Android 5.0 for developers](https://developer.android.com/about/versions/lollipop) — the WebView updated through Google Play * [`@rdlabo/ionic-theme-ios26`](https://www.npmjs.com/package/@rdlabo/ionic-theme-ios26) and [`@capgo/capacitor-native-navigation`](https://github.com/Cap-go/capacitor-native-navigation) — the community theme and the native tab bar plugin the lab used * [WebKit bug 245510](https://bugs.webkit.org/show_bug.cgi?id=245510) — `backdrop-filter` with SVG filters * Apple's [iPhone Duo announcement](https://www.apple.com/newsroom/2026/09/apple-unveils-iphone-duo/), [Preparing your app for iPhone Duo](https://developer.apple.com/documentation/technologyoverviews/preparing-your-app-for-iphone-duo), and the tech talks [Prepare your app for iPhone Duo](https://developer.apple.com/videos/play/tech-talks/111461/) and [Raise the bar with iPhone Duo](https://developer.apple.com/videos/play/tech-talks/111462/) * [The lab repository](https://github.com/modaal-agent/lab-replicate-parro-crossplatform) — [spec 001](https://github.com/modaal-agent/lab-replicate-parro-crossplatform/blob/main/specs/001-four-tab-shells/spec.md) holds the measurements (§14–§16 for the two variants, §19 for the wide layout, §20 for the native tab bar), and [DISCOVERY.md](https://github.com/modaal-agent/lab-replicate-parro-crossplatform/blob/main/DISCOVERY.md) the iPhone Duo review * [The Duet framework on GitHub](https://github.com/modaal-agent/duet) and the [duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials) — the framework, and the Foyer app whose tab bars this page links ## Read next The full survey — Ionic/Capacitor's cell on the quadrant, beside Flutter, and every other framework on the same three questions. The other JavaScript stack: the same language, but native views drawn by Fabric in place of a web view. How Duet keeps two native interfaces honest: the presentation contract and recorded divergence. What Duet is, the combinations that scaffold it, and the six-step loop every feature follows. # Duet vs React Native: Shared Logic, No JS Runtime Source: https://docs.modaal.dev/articles/duet-vs-react-native React Native runs shared logic in a shipped JavaScript engine and draws native views. Duet compiles a shared Kotlin core and verifies parity with recordings. React Native and Duet agree on one thing most comparisons miss: both draw the interface with native platform views. They part ways on where the logic runs — React Native ships a JavaScript engine in the app and runs your code there, while **Duet**, Modaal's cross-platform parity framework for native iOS and Android apps, compiles a shared Kotlin core into both apps and verifies their agreement with recorded fixtures. Which trade fits depends on your team, your dependencies on native modules, and what you need proven about behavior. *Last reviewed: September 1, 2026. React Native version and capability claims were checked against React Native's official documentation on this date.* ## How do Duet and React Native differ? | | React Native | Duet | | - | - | - | | Who draws the screen | The platform — Fabric composes real UIKit views and Android Views from React components | The platform — SwiftUI on iOS, Jetpack Compose on Android, written per platform | | Where feature logic lives | Once, in JavaScript/TypeScript, on the Hermes engine the app ships | Once, in Kotlin, compiled into both apps as a library | | What proves the two platforms agree | One implementation — until code crosses into a native module | Recorded fixtures replayed on both platforms; CI compares byte-for-byte | | The interface source | One React component tree, mapped to native views on each platform | Two view layers, each authored in its platform's own toolkit | | Customizing beyond the catalog | Core components are a small set; anything else is a library or a Native Component written per platform | Every control is the platform's own; customization is ordinary SwiftUI and Compose code | | A native, perf-heavy island | Animations can run on the native driver; a camera, video or GPU island is a Fabric Native Component plus a Native Module, written per platform, with its logic outside the JS bundle | Ordinary SwiftUI/UIKit and Compose/Android code, no bridge; the reducer decides, a worker runs the pipeline, and the seam is recorded | | Native code you write and compile for it | Yes: Objective-C++ on iOS and Kotlin or Java on Android, compiled by Xcode (through CocoaPods) and Gradle in the app build, with Codegen deriving the native interface from the TypeScript spec; an npm library spares the writing, not the compiling | Yes, by design: Swift compiled by Xcode, Kotlin compiled by Gradle | | SwiftUI and Compose in the island | Yes, wrapped: a Fabric Native Component hosts a `UIView` or Android `View`, so SwiftUI enters through `UIHostingController` and Compose through `ComposeView`, with Objective-C++ glue on iOS; with Expo, Expo UI builds UI directly with SwiftUI and Jetpack Compose from React, and Expo modules are written in Swift and Kotlin | They are the interface: SwiftUI on iOS, Jetpack Compose on Android, by construction | | Dependency manager on iOS | CocoaPods (SwiftPM support is experimental as of 0.87) | Swift Package Manager and Gradle — the platforms' defaults | ## Where does the logic run? A React Native app ships [Hermes](https://reactnative.dev/docs/hermes), a JavaScript engine tuned for the platform — since React Native 0.84 (February 2026), Hermes V1 is the default on both platforms, with measured wins the team published from Expensify's app: 3.2–9% faster bundle load and 2.5–7.6% faster time-to-interactive. The engine is good; the structural fact stands regardless: your rules execute in a bytecode VM the app carries, one abstraction away from the platform's own execution model. Duet's shared core is Kotlin compiled against each platform — Kotlin/Native machine code inside the iOS app, ordinary JVM bytecode on Android's ART. There is no bundled language VM or interpreter in either app; the core adds about 3 MB to the Android app, measured on the shipped app Modaal's Duet pages quote. ## Has the New Architecture closed the native gap? Substantially, on the rendering side. Comparisons that describe an asynchronous bridge describe React Native before 0.82 (October 2025): since that release the old bridge cannot be enabled, and its code has been deleted from default builds since 0.84. [Fabric](https://reactnative.dev/architecture/fabric-renderer) renders synchronously and composes genuine platform views, which is why this page places React Native beside Duet on the who-draws axis, not beside Flutter. What the New Architecture does not change is the boundary. React Native's [own documentation](https://reactnative.dev/docs/native-platform) states it: platform capabilities not covered by React Native or a library require a Native Module or Native Component, written in Kotlin and Swift/Objective-C per platform. Logic that crosses that line is implemented twice and leaves the shared-behavior guarantee — agreement across the boundary is maintained by review and QA. That is the precise, checkable form of this comparison: not "is React Native native enough," but *how much of your product's logic will live beyond the shared implementation, and what checks it there.* Duet's answer to the same question is that the logic lives in [reducers](/articles/duet-glossary#reducer) — pure, shared, replayed on both platforms — and platform code sits behind [workers](/articles/duet-glossary#worker) whose requests and results are part of the recorded contract: the fixture asserts what was requested and what came back, so the seam itself is inside the verified surface. The same boundary bounds customization. The [core components](https://reactnative.dev/docs/components-and-apis) are a small set — View, Text, Image, TextInput, Pressable, ScrollView — styled through a cross-platform flexbox subset; a control beyond them comes from a library or is a Native Component written in Swift and Kotlin, and anything outside the React tree — a home-screen widget, a share extension — is platform code regardless. React Native fits UI-neutral business applications whose screens are the same on both platforms; the more the interface must vary by platform, the more of it is written natively, twice, with the framework in between. ## What proves the two platforms agree? React Native: one implementation, so agreement is by construction inside the JS bundle and by convention beyond it. Duet: both apps replay the same recorded fixtures and CI compares byte-for-byte — 6 of 6 recordings byte-identical across the Swift/Kotlin boundary in the adoption measurement. [Should you share code between iOS and Android?](/articles/share-code-ios-android) develops the by-construction/by-verification distinction; the practical difference is what happens when platforms drift: a bug report in one model, a failing build in the other. ## What if one screen must be native and fast? React Native's escape is shorter than Flutter's, because the tree is already native views. Animation-heavy screens have a route inside the framework: the [Animated API's native driver](https://reactnative.dev/docs/animations#using-the-native-driver) sends the whole animation to native before it starts, so it runs on the UI thread even when the JavaScript thread is blocked — limited, per the docs, to non-layout properties such as `transform` and `opacity`. A custom-rendered or capture island is a [Fabric Native Component](https://reactnative.dev/docs/fabric-native-components-introduction) — a React component wrapping a host view, for when "core components are not sufficient" — written in Kotlin or Java on Android and Objective-C++ on iOS per the guide, usually paired with a Native Module for the pipeline itself: camera control, video encoding, audio processing. The drawbacks: the island is written twice, the iOS half is Objective-C++ rather than Swift, its props and events cross the JavaScript boundary, and the pipeline's logic lives in the by-convention zone the boundary section above describes — outside the shared bundle, kept equal by review. **Do you write and compile native code for it?** Yes, unless an npm library covers the island exactly. A Fabric Native Component is Objective-C++ on iOS and Kotlin or Java on Android, compiled by Xcode and Gradle in the app build, with Codegen deriving the native interface from the TypeScript spec. A library with native code spares you the writing, not the compiling: its native source is built into your app the same way. **Can the island be SwiftUI or Compose?** Yes, wrapped. A Fabric Native Component hosts a `UIView` on iOS and a `View` on Android, so a SwiftUI view arrives through [`UIHostingController`](https://developer.apple.com/documentation/swiftui/uihostingcontroller) — "a UIKit view controller that manages a SwiftUI view hierarchy" — and Compose through [`ComposeView`](https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/compose-in-views), which "is an Android `View`"; React Native's own component and module guides show the iOS glue in Objective-C++ and mention Swift nowhere. Expo, the framework React Native's documentation recommends, shortens both routes: the [Expo Modules API](https://docs.expo.dev/modules/overview/) lets you "write Swift and Kotlin to add new capabilities to your app with native modules and views" (its reference says rendering SwiftUI views directly is planned, with `UIHostingController` as the current route), and [Expo UI](https://docs.expo.dev/versions/latest/sdk/ui/) is "a set of components that allow you to build UIs directly with Jetpack Compose and SwiftUI from React". In a Duet app the island is ordinary platform code — AVFoundation and Metal in the SwiftUI app, CameraX and MediaCodec in the Compose app — with nothing between it and the OS. The [reducer](/articles/duet-glossary#reducer) decides when capture starts, which filter applies and what happens with the result; a [worker](/articles/duet-glossary#worker) runs the pipeline. What is recorded and byte-compared is that seam: the requests the reducer makes and the results that come back. The pipeline's internals are outside the verified surface, and the island is written twice, once per platform: Swift compiled by Xcode, Kotlin compiled by Gradle, by design. ## What does React Native do better? * **The talent pool and the ecosystem.** JavaScript/TypeScript is the largest developer population there is, and npm's breadth has no counterpart in any mobile stack. If your team is web engineers, React Native meets them where they are. * **One interface implementation.** The React component tree is written once; Fabric maps it to native views on both platforms. Duet's two view layers are written per platform. * **Framework maturity around the edges.** The project [recommends starting with a framework such as Expo](https://reactnative.dev/docs/environment-setup), and that layer — routing, builds, updates, deployment — is deep and battle-tested. * **Web from the same skills.** React on the web and React Native on mobile share idioms, components patterns, and often engineers. * **Governance breadth.** React and React Native moved to the React Foundation under the Linux Foundation in February 2026, with Amazon, Meta, Microsoft, Expo, Callstack and others as founding members — the project's stewardship no longer rests on one company's roadmap. ## When should you pick which? Pick React Native for UI-neutral business applications — forms, lists, dashboards, content — when your engineering strength is web/TypeScript, native-module and native-component needs are modest or covered by maintained libraries, and one interface implementation matters more than per-platform idiom. Pick Duet when the logic is the risk surface — rules-dense domains where "both apps do the same thing" needs a mechanical check — when you want no second runtime in the binary, or when the interface must be each platform's own toolkit, authored natively. The framing note this site puts on every comparison: Modaal builds native-first, with both interfaces in the platforms' own toolkits and no shipped runtime — a constraint fixed before any framework evaluation. React Native was ruled out by that constraint, not by a benchmark; this page compares documented properties, not race results. ## Common questions The views are native — Fabric composes real UIKit views and Android Views. What is not platform-native is the layer deciding what to render (React, in JavaScript) and the styling system (flexbox everywhere). In practice React Native apps can look and feel very close to native; the remaining gap is idiom and the boundary cases, not the widget identity. At author time, largely yes — 0.87 even makes the Strict TypeScript API the default. The difference is at the boundary and at runtime: types are erased before Hermes executes, and the JS-to-native seam is where mismatches surface. Duet's seam is compiled end to end — a contract change that breaks a caller fails the build on both platforms. Shipping JavaScript means the bundle can be updated without a store release, within store policies — a genuine operational advantage of the runtime model, and Expo's update service productizes it. A compiled core cannot do this; Duet ships through the stores like any native app. There is no React-Native-to-Duet converter, and this site does not claim one. The honest route is the one the share-code page describes for any rewrite decision: if the app's logic is well-tested and its native-module surface is small, staying put is usually right; a move to Duet is a re-authoring of features against recorded specs, taken one feature at a time. React Native offers an agent the largest training corpus of any stack and one language end to end. Duet offers machine-checkable gates: a screen cannot wire to logic without a passing recording, and CI replays the corpus on every push. One optimizes for the agent's fluency, the other for catching the agent's mistakes; the iOS architecture comparison scores the axis in detail. Duet projects are scaffolded by [Modaal](https://modaal.dev) — [starting a Duet project](/articles/duet#starting-a-duet-project) walks through the two template cards. ## Sources and further reading * [React Native documentation](https://reactnative.dev/docs/getting-started) — the environment-setup framework recommendation, Hermes, releases * [Native Modules and Native Components](https://reactnative.dev/docs/native-platform) — the official statement of the per-platform boundary * [Fabric renderer](https://reactnative.dev/architecture/fabric-renderer) — how React components become platform views * [React Native 0.82](https://reactnative.dev/blog/2025/10/08/react-native-0.82) and [0.84](https://reactnative.dev/blog/2026/02/11/react-native-0.84) release posts — New Architecture-only builds, Hermes V1, legacy code removal * [The React Foundation announcement](https://www.linuxfoundation.org/press/linux-foundation-announces-the-launch-of-the-react-foundation) — governance since February 2026 * [Fabric Native Components](https://reactnative.dev/docs/fabric-native-components-introduction) and the [Animated native driver](https://reactnative.dev/docs/animations#using-the-native-driver) — the escape-to-native and in-framework routes for a heavy screen * [Expo Modules API](https://docs.expo.dev/modules/overview/) and [Expo UI](https://docs.expo.dev/versions/latest/sdk/ui/); Apple's [`UIHostingController`](https://developer.apple.com/documentation/swiftui/uihostingcontroller) and Android's [`ComposeView`](https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/compose-in-views) — how SwiftUI and Compose enter a native component * [The Duet framework on GitHub](https://github.com/modaal-agent/duet) — both flavors, the recording toolchain and the versioned contracts ## Read next The full survey — including the quadrant cell React Native shares with .NET MAUI, and the one Duet shares with KMP practice. The other one-codebase heavyweight — different answer on who draws the screen. The decision one level up: write twice, share everything, or share the logic. What Duet is, the combinations that scaffold it, and the six-step loop every feature follows. What to check before committing to a builder — output, ownership, and the framework it locks you into, on modaal.dev. # How to Add Firebase to an iOS App: Auth, Database, Analytics Source: https://docs.modaal.dev/articles/firebase Connect Firebase to your iOS app — authentication, cloud database, file storage, functions, analytics, and crash reporting — in a few clicks from Modaal. ## What is Firebase for? Firebase is Google's **backend-as-a-service** for mobile apps. You don't run servers, manage databases, or write auth flows from scratch — Firebase gives you all of that through a small set of SDKs. Typical things you'd use it for: Email+password, Apple, Google, phone (SMS code), anonymous — with one line per provider. Users stay signed in automatically across app launches. Save user data, share it across devices, and collaborate in real time. **Firestore** keeps a phone's local cache in sync even when offline. Need a **relational** backend — tables, joins, transactions? **Firebase Data Connect** gives you managed Postgres with a typed Swift SDK. See the dedicated guide. User avatars, attached photos, recorded audio — anything that's too big to go in the database. Server-side code you don't have to host. Run logic on signup, send push notifications, call paid APIs without exposing keys in the app. See which screens users visit, what they tap, where they drop off. Free, unlimited. Real crash reports from real users, grouped by cause, with stack traces and breadcrumbs. Required reading before every release. Send notifications to one user, a segment, or everyone — from the Firebase console or your own backend. Flip feature flags, tune limits, A/B test copy — without shipping a new app version. **Need a relational database?** Firestore is a document store — it's great for document-shaped data and real-time sync, but it's not Postgres. If your data is naturally relational (strict schemas, complex joins, multi-row transactions), you don't have to leave Firebase: use **[Firebase Data Connect](/articles/firebase-data-connect)**, Google's managed Postgres backend, which Modaal sets up and manages from the same Firebase panel. *** ## Step-by-step setup Open the **Firebase** tab in Modaal (the flame icon in the left sidebar of your project). The panel walks you through connecting your Google account, picking (or creating) a Firebase project, and wiring the iOS app. Click **Connect**. A browser window opens; sign in with the Google account you use (or plan to use) for Firebase. Modaal stores the access token locally and refreshes it automatically in the background — you don't sign in again every day. If your Firebase projects are under a different Google account than your primary sign-in, sign in with that one. You can change accounts later from the Firebase panel's "⋯" menu. Some operations (project creation, deployments) go through Google's `firebase` command-line tool. Modaal checks whether it's installed and offers to install it for you if not. Click **Install** — this runs `npm install -g firebase-tools` behind the scenes. The **Firebase project** dropdown lists every project visible to the signed-in Google account. * **Reuse an existing project** (e.g., one you already created in the Firebase console for another app) by picking it from the list. * **Create a new one** by selecting **— Create Project —** at the top of the dropdown. Modaal creates it under your Google account using the `firebase projects:create` CLI command. The panel compares your iOS bundle identifier (from `xcodegen.yml`) against the iOS apps registered in the Firebase project. * If there's a match, you're done — Modaal downloads `GoogleService-Info.plist`, drops it into `src-ios/App//`, and wires it into the Xcode project. * If there's no match, click **Register this app**. Modaal registers the bundle id with Firebase and downloads the plist automatically. The **Deployable services** card lets you toggle Firestore, Cloud Storage, Cloud Functions, and Hosting. Each toggle scaffolds the local files Firebase expects (rules, TypeScript function starter, static site with AASA) and adds the matching top-level key to `firebase/firebase.json`. You can click **Initialize Firebase project** to turn them all on in one step. From the chat, say: > "Add Firebase sign-in with Apple, and let users save their favorite recipes to Firestore." The agent reads Modaal's Firebase integration knowledge base, picks the right wrappers, writes the Swift code, and runs the build. When it needs something only a human can do — enabling a provider in the Firebase console, uploading an APNs auth key for push — it tells you exactly what to do and where. *** ## How Modaal wires Firebase — and why Modaal doesn't drop the vanilla `firebase-ios-sdk` into your app. It adds a small Swift package called [`modaal-firebase-wrappers`](https://github.com/modaal-agent/modaal-firebase-wrappers) that wraps each Firebase SDK behind a **protocol**. Your app depends on `ModaalFirebaseAuth`, `ModaalFirebaseFirestore`, etc. — one product per service, each re-exporting the parts of the underlying SDK you need, plus a protocol you can inject and mock. ### Why not vanilla `firebase-ios-sdk`? With the vanilla SDK, every screen that reads Firestore depends on `Firestore` directly. That means **every unit test needs a real Firestore emulator running**, or a tangled manual mock. With `ModaalFirebaseFirestore`, your screen depends on a `FirestoreServiceProtocol`. Unit tests inject a fake that returns fixed documents — no emulator, no network, milliseconds per test. Same story for Auth, Functions, Storage, Analytics, and Remote Config. Each one ships a protocol and a ready-made mock you can drop into test targets. When the agent writes Firebase code, it needs to know the SDK's types and method signatures. The vanilla `firebase-ios-sdk` is **over a million tokens** of symbol information across a dozen modules. The agent can't hold that in context, so it either hallucinates APIs or skips parts of the SDK. Modaal's wrappers are tiny — a few hundred lines per product. The agent reads the **protocols**, sees exactly what's available, and writes code that compiles, with fewer retries and lower bills. The app builds its service dependencies once, at launch, in a single composition-root file. Swapping `FirestoreService()` (real) for `FakeFirestoreService(fixtures: ...)` (preview / UI test) is a one-line change. No conditional compilation, no feature flags, no `#if DEBUG`. This is the same pattern used by RIBs and clean-architecture iOS apps — the wrappers just make it easy for you without writing boilerplate. Google bumps the Firebase SDK every few weeks. Most updates are silent, but occasionally a public type gets renamed or an enum shifts. When your code depends on `ModaalFirebaseAuth` instead of `FirebaseAuth`, the upgrade lands in the wrapper — your app code usually doesn't need to change. ### What Modaal puts in your project * **`src-ios/App//GoogleService-Info.plist`** — the per-app config file downloaded from Firebase. Added as a resource in `xcodegen.yml`. * **`Package.swift` / xcodegen dependency on `modaal-firebase-wrappers`** — the SPM package. You import only the products you need (e.g., `ModaalFirebaseAuth`, `ModaalFirebaseFirestore`). * **`firebase/firebase.json`** — the CLI config. One top-level key per enabled service. * **`firebase/.firebaserc`** — pins the default Firebase project id for CLI commands. * **`firebase/firestore.rules`**, **`firebase/storage.rules`** — security rules (start as default-deny). * **`firebase/functions/`** — TypeScript + Node.js starter for Cloud Functions, with `package.json`, `tsconfig.json`, example callable. * **`firebase/hosting/public/`** — static-site root, including the Apple App Site Association (AASA) file pre-wired with your bundle id and team id for universal links. * **`.modaal/firebase_deployments.json`** — Modaal-owned ledger tracking the Firebase project id, iOS app id, and content hashes of what's been deployed. Not secret; safe to commit. * **A `[Firebase Crashlytics] Upload dSYMs` postBuildScript** — automatically added to your Xcode target so crash reports are symbolicated without extra work. **GoogleService-Info.plist** is safe to commit to source control. It's not a secret — it identifies your app to Firebase, but access control lives in Firestore/Storage security rules and in provider restrictions, not in the plist itself. *** ## What do people build with Firebase? ### Scenario 1: Add sign-in with Apple + save favorites You have a working app. You want users to sign in with Apple and save a list of favorites that syncs across their devices. Click the flame icon, connect your Google account, pick (or create) a Firebase project, register the iOS app. Covered above in **Step-by-step setup**. Firebase needs Apple as a **provider** turned on. The agent can't do this — it's a privileged action in your Firebase project. Open [Firebase Console → Authentication → Sign-in method](https://console.firebase.google.com), enable **Apple**, and save. (The console will ask you to register a Service ID in your Apple Developer account — it walks you through it.) In chat: > "Add Apple sign-in using `ModaalFirebaseAuth`. After sign-in, save the user's uid to the `users/{uid}` document in Firestore. Add a Favorites screen that reads and writes `users/{uid}/favorites`." The agent will: * Add the `ModaalFirebaseAuth` and `ModaalFirebaseFirestore` products to `Package.swift`. * Add a "Sign in with Apple" button and a sign-in flow. * Add a `FavoritesService` that talks to Firestore through the protocol. * Add a real-time listener so favorites sync across devices without a refresh button. * Run the build and fix any errors. The scaffolded `firestore.rules` is default-deny for safety. Ask the agent: > "Update `firestore.rules` so users can read and write their own `users/{uid}/**` subtree but nothing else." Click **Deploy** on the Firestore row in the Firebase panel. The rules go live in \~10 seconds. ### Scenario 2: Catch crashes in production You're a week before launch and want to know what breaks on real devices. From chat: > "Add `ModaalFirebaseCrashlytics` to the app. Log non-fatal errors from the top-level `catch` branches in `FavoritesService`." The agent adds the product, initializes it at launch, and wraps your `catch` blocks with `Crashlytics.record(error:)`. The dSYM upload script was already added when you registered the iOS app, so symbolicated reports arrive automatically. Ask: > "Add a hidden `crashNow()` trigger in the Settings screen that forces a crash, so I can verify Crashlytics is catching events end-to-end." Build, run on device, crash the app, relaunch. Within a couple of minutes, the crash appears in [Firebase Console → Crashlytics](https://console.firebase.google.com). > "Remove the `crashNow()` trigger from Settings." ### Scenario 3: Send a welcome notification You want users to receive a push notification the first time they sign in. In the Firebase panel's **Deployable services** card, toggle **Cloud Functions**. Modaal scaffolds `firebase/functions/` with a TypeScript starter. Cloud Functions require the **Blaze (pay-as-you-go) plan**. Free-tier quotas are generous, but you have to enter a credit card. Upgrade from the Firebase console if prompted. Push notifications go through Apple Push Notification service (APNs). Open your Apple Developer account → **Keys** → create a new key with **APNs** enabled → download the `.p8` file. Upload it to [Firebase Console → Project Settings → Cloud Messaging → Apple apps](https://console.firebase.google.com). > "Add `ModaalFirebaseMessaging` and request notification permission on first launch. When a new user is created in Firestore (`onDocumentCreated` trigger on `users/{uid}`), send them a welcome notification via FCM." The agent adds the iOS side (permission request, FCM token registration, foreground handler) **and** writes the Cloud Function in `firebase/functions/src/index.ts`. Run `cd firebase/functions && npm install && npm run build` (the agent can do this for you). Then click **Deploy** on the Functions row in the Firebase panel. Watch the logs stream in the modal. *** ## What do I deploy, and when? When the agent (or you) edits a file under the `firebase/` folder, those changes are **local only** until you click the matching **Deploy** button in the Firebase panel. The live app keeps running with the last-deployed version until then. The Firebase panel's **Deployable services** card shows one row per service with a colored dot: * 🟢 **Green** — what's live matches your local files. Nothing to do. * 🟡 **Yellow** — you have local changes that haven't been deployed yet. Click **Deploy** on that row. * ⚪ **Gray** — the service is set up locally but has never been deployed. Click **Deploy** to publish it for the first time. * 🔴 **Red** — the last deploy failed. Click **Deploy** again; the log window shows what broke. ### Changed this? Deploy that. | You edited or added… | Deploy this | What happens if you skip the deploy | | - | - | - | | **`firebase/firestore.rules`** — who can read/write your database | **Firestore rules** | The database keeps enforcing the old rules. Users can't reach new data, or can reach data you meant to lock down. | | **`firebase/firestore.indexes.json`** — added a new query in the app | **Firestore indexes** | New queries silently fail in the app with a "query requires an index" error. | | **`firebase/storage.rules`** — who can upload/download files | **Storage rules** | Same as Firestore rules — old rules stay in effect. | | **Anything inside `firebase/functions/`** — your server-side code | **Cloud Functions** | Clients keep calling the old version of your function until you redeploy. | | **Added an npm package** in `firebase/functions/package.json` | **Cloud Functions** (after `npm install && npm run build`) | The function crashes at cold-start because the package isn't in the deployed bundle. | | **Anything in `firebase/hosting/public/`** — your static site | **Hosting** | The public site keeps serving the last-deployed content. | | **The AASA file** (`apple-app-site-association`) for universal links | **Hosting** | iOS downloads the AASA from your live site, not from your computer. Until you redeploy, universal links behave however the previous AASA said. | | **Changed `DEVELOPMENT_TEAM`** in iOS Project Settings after Hosting was set up | **Hosting** | Modaal automatically rewrites the AASA locally, but universal links won't work with the new team id until the Hosting deploy publishes the updated file. | ### You do NOT need to deploy after: * **Swift code changes** in your iOS app — those go live when you run or ship the iOS app, not through Firebase deploy. * **Swapping the Google account connected to Modaal** — that's just a sign-in change. * **Turning Firebase Analytics events on/off in code** — events go live with the next iOS build. * **Enabling Apple / Google / phone sign-in** — that's done in the [Firebase Console](https://console.firebase.google.com) → Authentication → Sign-in method, not via Deploy. * **Uploading an APNs push key** — done in the Firebase Console, not via Deploy. * **Editing Remote Config values** — done in the Firebase Console → Remote Config (click Publish there). **Not sure what's pending?** Look at the dots in the Firebase panel. Any yellow, gray, or red dot is something worth deploying. The agent also tends to end its turn with a sentence like *"Now open the Firebase panel → Deploy on the Firestore rules row"* — that's your cue. *** ## Fix a broken Firebase setup When something goes wrong with Firebase, the best first move is almost always: Type: **"Check the Firebase setup for this project and fix anything that's broken."** The agent walks through every place Firebase wiring lives — the project settings file, the iOS build configuration, the `GoogleService-Info.plist`, the wrappers package, the crash-reporting upload step — and fixes whatever's out of place. For anything that can only be done by a human (like enabling a provider in the Firebase console or upgrading the billing plan), it tells you the exact button to click or URL to open. Prompts like this run with the [Plan toggle](/articles/modes-and-prompts) off. [Modaal](https://modaal.dev) also surfaces two ready-made prompts from the Firebase panel's "⋯" menu — click one and the agent does the rest: * **Fix Firebase integration** — brings your project's local files back in sync with Firebase (re-downloads the plist, re-links it in the iOS project, re-adds the crash-reporting upload step). * **Migrate to wrappers** — if your project still uses the vanilla Firebase SDK directly, this switches it over to the Modaal wrappers without changing how the app behaves. If the Firebase project already backs a web app, [migrating a Lovable app to a mobile app](https://modaal.dev/blog/migrate-lovable-app-to-mobile-app) covers which parts of that backend the native app reuses unchanged. ### Fix the common Firebase errors Google access tokens expire after about an hour. Modaal refreshes them automatically in the background when the panel opens. If the refresh fails because of a transient network issue, the banner clears on the next Refresh click. If it persists, click **Connect** again — a re-auth takes ten seconds and doesn't disturb any other Firebase state. The Google account you're signed in with isn't a member of that Firebase project. This usually means the project was created under a **different** Google account than the one you used to sign in. Options: * Click **Sign in with a different Google account** in the error banner and use the account that owns the project. * Or ask the project owner to invite you as a member in Firebase Console → Project settings → Users and permissions. Some operations (creating a project, deploying rules or functions) use Google's `firebase` command-line tool, which has its own separate sign-in. Modaal shows a **Sign in via Terminal** button when this is needed — click it, a terminal opens with `firebase login` pre-typed, finish the browser flow, click **Retry**. You only do this once per machine. Firebase Storage needs a one-time console bootstrap. Open [Firebase Console → Storage](https://console.firebase.google.com) and click **Get started**. Come back to Modaal and click Deploy again. Cloud Functions require Firebase's pay-as-you-go plan (free-tier quotas are generous, but a credit card is required). Click the URL in the error to upgrade, then retry the deploy. Checklist: * Did the app crash on a **real device**, not the Simulator? (Simulator crashes don't upload.) * Did you **relaunch** the app after the crash? Crashlytics uploads on the next launch, not at crash time. * Is the `[Firebase Crashlytics] Upload dSYMs` post-build script present in your Xcode target? (The Firebase panel's status banner shows this explicitly. If it's missing, click **Fix Firebase integration**.) This script turns raw crash addresses into readable function names — without it, reports show up but you can't tell where the crash happened. The **AASA file** at `firebase/hosting/public/.well-known/apple-app-site-association` is what tells iOS to open your app when someone taps a link to your site. It's generated when Hosting is first enabled, using whatever Apple Team was selected at that moment. If you change the team later from the iOS Project Settings panel, Modaal automatically rewrites the AASA file to match. If you edited it by hand before changing the team, re-run **Deploy** on the Hosting row to publish the refreshed file. Click the refresh icon (↻) at the top of the Firebase panel to re-fetch the list of projects. If the project still doesn't appear, the Google account signed in to Modaal probably isn't the one that created it — re-check which account you used in the Firebase console. From the Firebase panel's "⋯" menu, pick **Disconnect**. This clears the stored Google token but **does not** touch files in your repository (`GoogleService-Info.plist`, `firebase/…`, Package.swift, etc.) — your app keeps working and can be re-connected to the same or a different Firebase project later. To remove Firebase from the project entirely, ask the agent: > "Remove all Firebase integration from this project — the wrappers package, the plist, the post-build script, and the `firebase/` directory." **Still stuck?** Share a screenshot of the Firebase panel (the top status banner shows exactly which piece of the setup is out of place) in our [Discord](https://discord.gg/ng84Jm92tm) — it's usually enough for us to pinpoint the fix. # Firebase Data Connect: A SQL Backend for Your iOS App Source: https://docs.modaal.dev/articles/firebase-data-connect Add a real SQL (PostgreSQL) backend to your iOS app — relational tables, joins, transactions, and a typed Swift SDK — managed from Modaal's Firebase panel. Firebase **Data Connect** is Google's SQL backend for Firebase: a fully-managed **PostgreSQL** database (running on Cloud SQL) that you model with a GraphQL schema and query with a **typed Swift SDK** generated for you. It sits next to the rest of Firebase — same project, same sign-in, same billing — and coexists with Firestore in the same app. [Modaal](https://modaal.dev) manages the whole lifecycle from the **Firebase panel**: scaffold the service, generate the typed Swift SDK, and deploy the schema — no Terminal, no hand-written GraphQL plumbing. [Your agent](/guides/connect-your-agent) writes the schema and the Swift data layer; you click the buttons that talk to Google. Where the SQL schema already exists behind a web app, [migrating a Lovable app to a mobile app](https://modaal.dev/blog/migrate-lovable-app-to-mobile-app) covers which parts of that backend the native app keeps. This article is the companion to the main **[Firebase](/articles/firebase)** guide. Everything there — connecting your Google account, installing the CLI, the Deploy panel, Crashlytics, sign-in — still applies. This page covers only the SQL/Data Connect piece. ## Data Connect vs Firestore — which one? Both are databases in the same Firebase project, but they solve different problems. Firestore stores **documents**; Data Connect stores **rows in relational tables**. Your data is **relational** — separate `User`, `Friendship`, `Post`, `Order` tables joined by foreign keys. You need **joins**, **server-side aggregation**, or **transactions** that write several rows atomically. You want a **typed query surface** the compiler checks. Your data is **document-shaped** — self-contained or tree-shaped records. **Offline-first** reads and writes are a product requirement. Per-document writes dominate, and you're fine with weakly-typed dynamic field access. | Pick **Data Connect** when… | Pick **Firestore** when… | | - | - | | The schema has foreign keys, many-to-many tables, or joins are common | Documents are self-contained or naturally tree-shaped | | Server-side aggregations or multi-row transactional writes are required | Per-document writes are the dominant pattern | | The model is naturally normalized (`User` / `Friendship` / `Post` tables) | The model is naturally denormalized (a user doc with an embedded friend list) | | You want a typed Swift API generated from a schema | You're OK with dynamic, untyped field access | | Offline-first is **not** a hard requirement | Offline reads/writes are a product requirement | **If both fit, default to Firestore** — its offline cache, security rules, and indexes are more mature. Choose Data Connect when the schema *genuinely* needs relational semantics and you can accept the requirements below. You can also use **both in the same app** — see [Using both together](#using-both-together). ## Before you start — three requirements Data Connect asks a little more of your project than Firestore does: The generated Swift SDK is built on Apple's **Observation framework** (`@Observable`), which is iOS 17-only. Modaal raises your app's deployment floor to iOS 17 automatically when it wires the SDK in. If your app must ship to iOS 16 or earlier, Data Connect isn't an option — use Firestore. Data Connect provisions a **Cloud SQL Postgres instance**, which is paid infrastructure — so the Firebase project must be on the **Blaze** plan (a credit card on file). New services start on the SQL Connect no-cost trial, but billing must be enabled. If it isn't, the first deploy tells you in the log. Every Data Connect action is gated on a **region** being set (the panel's **Region** card). The region fixes where your Cloud SQL database physically lives, so it's **permanent** — changing it later means migrating the whole database. It also carries **data-residency / GDPR** weight: if you store EU users' personal data, pick an EU region (`europe-west1`, `europe-west3`, …) so the data stays in the EU. The Data Connect buttons stay disabled with a *"Set the project region first"* hint until you've chosen one. ## The three panel actions Data Connect lives in the **Firebase** panel (the flame icon in the left sidebar). Once a region is set, three actions drive everything — and, exactly like every other Firebase operation, each one **streams its logs into a modal** and runs against Google's servers for you. The agent points you at the button; it never shells out to the `firebase` CLI itself. On a project with no Data Connect yet, the **Add a service** card shows a **Set up** button. Click it and Modaal scaffolds `firebase/dataconnect/` — a `dataconnect.yaml`, an (empty, comment-only) `schema/schema.gql`, and a `connector/` with `queries.gql` + `mutations.gql` — deriving sensible defaults from your project name (service id, Cloud SQL instance id, and the Swift package name, e.g. package `MyAppConnector`, service `myapp`). An **empty schema is valid**. The scaffolded comment-only `schema.gql` compiles and deploys fine — it provisions the service and the Cloud SQL instance with no tables. Add tables when you're ready; there's no "you must define something first" step. The **Data Connect** row has a **Generate SDK** button (it reads **Regenerate SDK** once the schema has changed). It runs Google's code generator server-side, commits the typed Swift package under `src-ios/Libraries//`, and **auto-wires** it into your app's main Swift package (raising the iOS-17 floor). You don't touch `Package.swift` or `xcodegen.yml` — the panel does it. Schema deploys ride the **shared Deploy button** below the service rows — Data Connect is just another target there, alongside Firestore, Storage, Functions, and Hosting. When you've edited the schema, the Data Connect row shows a **Schema changed** badge and the Deploy button includes it. Deploying applies the change to the live Postgres database (see [Deploying schema changes](#deploying-schema-changes) for the additive-vs-destructive rules). **"Regenerate SDK" and "Deploy" are two different things — and a schema edit usually needs both.** The **SDK stale** badge means your *local* typed Swift package is behind your `.gql` files → click **Regenerate SDK**. The **Schema changed** badge means the *live database* is behind your local schema → click **Deploy**. They drift independently. After editing the schema you almost always want to *regenerate the SDK* **and** *deploy the schema*. The Data Connect row also shows a one-line status — the service id, package name, and region — plus an aggregate badge: **Not generated**, **Not wired**, **Wired**, **SDK stale**, or **Schema changed**. ## What Modaal puts in your project * **`firebase/dataconnect/dataconnect.yaml`** — the service config: service id, region, and the Cloud SQL instance id. The service id and instance id are **frozen after the first deploy** (renaming a live service is a data migration, not an edit). * **`firebase/dataconnect/schema/schema.gql`** — your tables, written in Data Connect's GraphQL dialect (`@table`, `@ref`, `@index`, `@auth`, …). Starts empty. * **`firebase/dataconnect/connector/queries.gql`** and **`mutations.gql`** — your read and write operations. Split by convention; the code generator turns each one into a typed Swift call. * **`src-ios/Libraries/Connector/`** — the **generated, committed** Swift package. It's checked into source control on purpose, so teammates can build without installing the Firebase CLI. Don't hand-edit it — it's overwritten on every regenerate. * **A `dataconnect` block in `firebase/firebase.json`** — so `firebase deploy` picks the service up. The generated Swift package is **committed source**, not build output. That keeps builds deterministic and means only the person editing the schema needs the Firebase CLI. When you (or the agent) change the schema, regenerate and commit the refreshed package. ## How the agent builds with it You don't write GraphQL or wire up Swift generics by hand. Ask the agent, and it reads Modaal's Data Connect knowledge base and does the modeling for you: > "Model a friends feature in Data Connect: a `User` table keyed by the Firebase Auth uid, a `Friendship` join table with an `isActive` flag, and a `Memory` table each user can share with several friends. Add queries for my friends list and my incoming feed." The agent will: * Write the tables and relationships in `schema/schema.gql`, and the read/write operations in `queries.gql` / `mutations.gql`. * Tell you to click **Regenerate SDK** and **Deploy** in the panel (only you can push to Google). * Write the iOS data layer behind a **per-feature repository protocol** — one clean protocol your screens depend on, with the generated SDK hidden inside a single implementation file. That keeps your views testable (they inject a fake repository) and swappable (Data Connect could be replaced without touching feature code). * Keep server-only operations (privileged cross-user reads, account-deletion cascades, invite redemption) **out of the shipped client SDK** by putting them in a separate connector, so they never appear as callable symbols in your app binary. The architecture the agent follows — protocol-per-feature, a domain type mirrored at the boundary, Combine and async variants side by side — mirrors the Modaal Firebase **wrappers** pattern described in the [Firebase guide](/articles/firebase#how-modaal-wires-firebase-and-why). You get the same testability and token-efficiency benefits; you just don't have to think about them. ## Keep the UI in sync with live queries Data Connect queries can be **observed** — the UI updates when the underlying rows change — the same way Firestore listeners work. The agent bridges the generated observable query into your app's Combine or SwiftUI layer and cleans up the subscription when the view goes away. Refreshes can be **automatic** (a query keyed by primary key refreshes when a matching write executes) or **time-based** (a periodic backstop that catches changes made on another device). You don't manage any of this directly — you ask for "a friends list that stays in sync across devices" and the agent picks the right refresh strategy. ## Deploying schema changes Deploying Data Connect is different from deploying rules or functions, because **the database has data in it**. Two kinds of migration: * **Additive** — a new table, a new nullable column, a new index. These apply **automatically**, no prompt. * **Destructive** — dropping a column, narrowing a type, removing a `NOT NULL` constraint. These are **blocked**: the deploy stops, the modal shows you the Postgres migration plan (the exact SQL), and an explicit **Apply destructive migration** button is the only way to proceed. **Read the SQL diff before you click Apply destructive migration.** A destructive migration runs with `--force` and can **permanently delete data** — it cannot be undone. The deploy log shows precisely what will be dropped. Never click it blind, and never let the agent tell you to click it without showing you the diff first. **Almost never.** When this opaque error appears right after the log's `ensuring required API … sqladmin.googleapis.com` lines, it's usually **not** a flaky backend — it's a *breaking change* (a destructive SQL migration, or a query field that changed from required to optional) that the non-interactive deploy can't prompt you to confirm. Retrying the same deploy just loops. Modaal detects this and surfaces a **Data Connect deploy blocked** gate with two ways forward: **Deploy with `--force`** (after you've read the diff in the log), or **Open in Terminal** (you run the deploy yourself and answer the breaking-change prompt interactively). When in doubt, use **Open in Terminal** so you can review it live. **The very first deploy provisions everything and takes several minutes.** It enables the required Google APIs and creates the Cloud SQL Postgres instance in your chosen region. The schema migration is skipped while the instance is still spinning up (that's expected, not an error) — the deploy still completes, and the *next* deploy applies your schema once the instance is live. ## Using both together Data Connect and Firestore coexist in one app with no special wiring — Modaal's Firebase SDK resolves to a single shared Firebase core, which the generated Data Connect package uses too. A common split: * **Firestore** — user profile documents, settings, chat messages, anything document-shaped or offline-first. * **Data Connect** — the relational core (posts ↔ authors ↔ tags, friendships, orders ↔ items), anything that benefits from joins or transactional consistency. Your screens depend on whichever repository fits the data; the two never reach across each other. Ask the agent to "keep profiles in Firestore but move the social graph to Data Connect" and it wires both. ## Fix greyed-out buttons, stale SDKs and blocked deploys You haven't set a **region** yet. Every Data Connect action (Set up, Generate SDK, and the shared Deploy) is gated on it. Open the **Region** card at the top of the Firebase panel and pick one — remember it's effectively permanent and has data-residency implications, so choose an EU region if you store EU personal data. Your generated SDK is **stale** — the schema (and the live server) moved on, but the local typed package still encodes the old operation shapes. The row shows an **SDK stale** badge. Click **Regenerate SDK** on the Data Connect row and rebuild. Always regenerate after every `.gql` edit. The app is being built against **iOS 16 or earlier**. The generated SDK needs iOS 17 (it uses the Observation framework). Modaal raises the floor when it wires the SDK in — if you see this, check that your app target's deployment target is 17.0 and rebuild. Ask the agent to "raise the iOS deployment target to 17 everywhere Data Connect needs it." Data Connect needs a Cloud SQL instance, which is paid infrastructure — the Firebase project must be on the **Blaze** plan. Open the [Firebase Console](https://console.firebase.google.com) → upgrade to Blaze (a credit card is required; free-tier quotas are generous), then retry the deploy. Modaal reconciles the on-disk config against its own records through the **top-level Firebase banner** (the wiring-health surface). If it's missing metadata it can backfill, the **Fix Firebase wiring** button does it (strictly additive — it never overwrites). If the on-disk ids *disagree* with what Modaal recorded (e.g. a renamed service), it routes you to a **Have the agent fix it** prompt — treat that as a reconciliation, not a silent rewrite, because a deployed service's id and Cloud SQL instance are frozen. See the **[Firebase guide's troubleshooting section](/articles/firebase#troubleshooting)** — account/token issues, the CLI login handoff, and the general "ask the agent to fix the setup" move all live there and apply here too. **Still stuck?** Share a screenshot of the Firebase panel (its top status banner shows exactly which piece is out of place) in our [Discord](https://discord.gg/ng84Jm92tm) — it's usually enough for us to pinpoint the fix. # How Android Apps Work: Anatomy of a Modern Android App Source: https://docs.modaal.dev/articles/how-android-apps-work An Android app is an APK or app bundle: a manifest declaring entry points, Jetpack Compose screens driven by state holders, and a Gradle build behind it. An Android app is a signed package — an APK, or an app bundle that Google Play turns into APKs — holding a manifest that tells the system where the app starts, compiled Kotlin code whose screens are functions that turn state into UI, and the resources the app draws on. This page walks that anatomy for readers who build software but haven't built for Android: what is in the package, where code starts running, how a screen is drawn, where the logic lives, and how the finished app reaches users. Each section links the page of [Google's developer documentation](https://developer.android.com/guide/components/fundamentals) that treats it in full. Block diagram of a modern Android app: AndroidManifest.xml declares MainActivity, which sets the Jetpack Compose composable tree as its content; state flows from a state holder to the composables and events flow back; services and background workers handle work that outlives the screen ## What is inside an Android app? The artifact users install is an **APK** — a signed archive of compiled code, resources (icons, strings, themes), and native libraries. What developers upload to Google Play is usually an **[app bundle](https://developer.android.com/guide/app-bundle)** (`.aab`), from which Play generates a smaller APK tailored to each device's screen density, CPU architecture, and language. At the root of the package sits **[AndroidManifest.xml](https://developer.android.com/guide/topics/manifest/manifest-intro)**. It names the app, lists every entry point the system may launch, and declares the permissions the app requests — camera, location, notifications. Android reads the manifest before running any of the app's code, so nothing undeclared can be launched or granted. ## Where does an Android app start? The entry points There is no single `main()` function to look for. The system starts an app at whichever declared entry point the situation calls for, and the manifest lists them all. An **[Activity](https://developer.android.com/guide/components/activities/intro-activities)** is the entry point for anything the user sees. When someone taps the app's icon, Android launches the activity the manifest marks as the launcher — conventionally named `MainActivity` — and hands it a window to fill. A modern app typically has one activity hosting every screen, switching between them in code. The **[Application class](https://developer.android.com/reference/android/app/Application)** is instantiated before any activity, once per process. Apps use it for initialization that everything else depends on — logging, dependency wiring, crash reporting. **[Broadcast receivers](https://developer.android.com/guide/components/broadcasts)** are entry points with no UI at all: the system invokes them when an event they registered for occurs — the device finished booting, connectivity changed, an alarm fired — even if the app isn't running. ## How is a screen drawn? Jetpack Compose Screens are written in **[Jetpack Compose](https://developer.android.com/develop/ui/compose/mental-model)**, Google's recommended toolkit for native UI: a screen is a [Kotlin](https://developer.android.com/kotlin) function annotated `@Composable` that describes what to show for the current state. When the state changes, the framework re-runs the affected functions and updates only what differs — the developer never mutates the screen by hand. A minimal but complete app: ```kotlin theme={null} class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { CounterScreen() } } } @Composable fun CounterScreen() { var count by remember { mutableIntStateOf(0) } Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, modifier = Modifier.fillMaxSize(), ) { Text("Taps: $count", style = MaterialTheme.typography.headlineMedium) Button(onClick = { count++ }) { Text("Tap me") } } } ``` (Imports omitted.) The activity's one job here is `setContent` — everything visible is the composable tree. Search results will also surface the older layer: **XML layout files** inflated into a tree of `View` objects, edited through code. That system still runs in production apps and in most tutorials written before Compose, which is why both appear when you search — but a new project's screens are Compose functions. ## Where does the app's logic live? Logic lives in a **state holder**: a class that owns a screen's state, exposes it as an observable value, and updates it in response to events from the UI — the pattern Google's [app architecture guide](https://developer.android.com/topic/architecture) recommends, with Jetpack's `ViewModel` as the common implementation. State flows one way, from the holder down into the composable tree; events flow back up as function calls. The payoff is that a screen's behavior can be exercised in a plain unit test — construct the state holder, send it events, assert on the state — with no device attached. ## What runs in the background? Work that must outlive the screen — uploads, sync, media playback — runs through the platform's [background-work facilities](https://developer.android.com/develop/background-work/background-tasks): scheduled workers for deferrable jobs, and foreground services for work the user is actively aware of, which must show a notification. Android decides when deferred work actually runs, batching it around battery and network conditions, so apps request work rather than simply spawning threads that the system may kill. ## How is an Android app built? Gradle Every Android app is built by **[Gradle](https://developer.android.com/build)**, driven by build files checked into the project — there is no IDE-owned project file that the build depends on. The `build.gradle.kts` of the app module declares the app's identity and its platform window: ```kotlin theme={null} android { namespace = "dev.example.counter" compileSdk = 36 defaultConfig { applicationId = "dev.example.counter" minSdk = 26 targetSdk = 36 } } ``` Three numbers define that window: `compileSdk` is the API the code compiles against, `minSdk` is the oldest Android version the app installs on, and `targetSdk` declares which version's behavior rules the app has been tested for. What each API level corresponds to, and how many devices each `minSdk` choice reaches, is the whole subject of [Android API levels: versions, codenames, and device coverage](/articles/android-api-levels). ## Where do you run it? Devices and emulators Development runs on a real device over USB or on the **[Android Emulator](https://developer.android.com/studio/run/emulator)** — a virtual device booted from a system image of a chosen API level, defined by an AVD ("Android Virtual Device") profile such as a Pixel 8 running API 36. The emulator runs the same system the phone does, so the app, its background workers, and its notifications behave as on hardware. On a Mac, one setup pass installs the SDK, a JDK, and a first emulator — [Set up the Android toolchain](/guides/setup-android) documents it. Android emulator screen showing the Compose counter app: a status bar, the text Taps: 7, and a Tap me button ## How does an app reach users? Through **Google Play**: the developer uploads the app bundle, and Play verifies, signs, and distributes per-device APKs. With [Play App Signing](https://developer.android.com/studio/publish/app-signing), Google holds the signing key and every update must come from the same developer account — devices refuse an update whose signature doesn't match the installed app. Play also enforces [a floor on `targetSdk`](https://developer.android.com/google/play/requirements/target-sdk) for new submissions and updates, so shipped apps track recent platform behavior. ## Common questions An APK is what a device installs and runs. An app bundle (`.aab`) is what a developer uploads to Google Play: a superset package from which Play builds a smaller APK per device configuration. Outside Play — a local build, a direct download — you deal in APKs. Not anymore, as a rule. An Activity is the entry point that owns a window; a modern app has one activity, and its screens are composable functions the app navigates between inside that window. Apps built earlier often used one activity per screen, and both designs run fine today. Only to read existing code and older tutorials. New screens are written in Jetpack Compose — Kotlin functions, no XML. The XML `View` system remains supported, and large apps commonly contain both while newer screens accumulate in Compose. Kotlin. Android's own APIs, documentation, and tooling are [Kotlin-first](https://developer.android.com/kotlin), and Compose is Kotlin-only. Java remains fully supported for existing code, so long-lived apps often carry both languages. Yes — the SDK, the emulator, and the JDK all run natively on macOS, alongside Xcode. [Set up the Android toolchain](/guides/setup-android) walks through the one-banner install Modaal provides. [Modaal](https://modaal.dev) builds the apps this page describes without you assembling the toolchain yourself; [Set up the Android toolchain](/guides/setup-android) covers what it installs. ## Sources and further reading * [Application fundamentals](https://developer.android.com/guide/components/fundamentals) — Google's overview of apps, components, and the manifest * [Introduction to activities](https://developer.android.com/guide/components/activities/intro-activities) — the entry-point contract in full * [Thinking in Compose](https://developer.android.com/develop/ui/compose/mental-model) — the state-driven UI model * [Guide to app architecture](https://developer.android.com/topic/architecture) — state holders and unidirectional data flow * [Background work overview](https://developer.android.com/develop/background-work/background-tasks) — workers, services, and when each applies * [Configure your build](https://developer.android.com/build) — the Gradle build system for Android * [About Android App Bundles](https://developer.android.com/guide/app-bundle) — the publishing format * [App signing](https://developer.android.com/studio/publish/app-signing) — how Play signs and verifies releases ## Read next Every Android version with its API level and codename, live device-coverage numbers, and how to choose a minSdk. iOS and Android from one shared core: feature logic written once, native SwiftUI and Jetpack Compose interfaces, and a CI gate that keeps the two apps in agreement. This page's anatomy, applied: what a Duet-built Android app puts in each module, shown on a shipped app. What a fixed architecture buys over a blank project: pure reducers, effects as data, and recorded behavior tests. # Kotlin/Native GC and Swift ARC in One iPhone App Source: https://docs.modaal.dev/articles/kotlin-native-gc-swift-arc How a Duet iPhone app runs its Kotlin core: the Kotlin/Native collector beside Swift's ARC, SKIE's generated Swift, who frees what, and how to check a build. The iPhone app of a Duet project on the Kotlin Multiplatform flavor runs two memory managers in one process. The Kotlin core runs under the Kotlin/Native garbage collector, which is linked into the app executable. The Swift code that calls the core runs under Swift's automatic reference counting (ARC). This page shows what the app links, what the collector costs on the main thread, who frees each object that crosses between the two, and the rules a Duet shell follows as a result. Every figure on this page comes from a build you can reproduce: **Foyer**, the app the [Duet tutorials](/tutorials/duet) build, at `tutorial9-complete`, and **Memory Lane**, a production app on the [App Store](https://apps.apple.com/us/app/memory-lane-private-journal/id6760589051) built on the same flavor. *Measured October 1, 2026, with Kotlin 2.4.10, SKIE 0.10.14 and Xcode 26.6 on an Apple M4 Pro.* ## What does the iPhone app link? The iPhone app links one static XCFramework, `FoyerKit.xcframework` in Foyer. Its iPhone slice, `ios-arm64/FoyerKit.framework/FoyerKit`, is an `ar` archive with two object files: * **`FoyerKit.framework.o`**, written by the Kotlin/Native compiler: the Kotlin core compiled to arm64 machine code, an Objective-C class for every exported Kotlin class, and the Kotlin/Native runtime (the memory allocator, the garbage collector and the safepoint machinery). * **`FoyerKit.o`**, written by Swift's compiler from source that SKIE generated: the Swift API your app calls. It contains no Kotlin code; it calls the Objective-C classes the first object defines. A static framework is copied into the app executable at link time, so the collector runs inside the app's own process, on threads the runtime starts. Block diagram of the Foyer app executable: the app's Swift code calls SKIE's generated Swift in FoyerKit.o, a 1.5 MB object with 5,009 Swift symbols and no Kotlin functions, which calls 229 Objective-C classes defined in FoyerKit.framework.o, a 16.2 MB object holding the compiled core (7,361 Kotlin functions, 559 Objective-C classes) and the runtime (allocator, concurrent mark and sweep collector, safepoints) Block diagram of the Foyer app executable: the app's Swift code calls SKIE's generated Swift in FoyerKit.o, a 1.5 MB object with 5,009 Swift symbols and no Kotlin functions, which calls 229 Objective-C classes defined in FoyerKit.framework.o, a 16.2 MB object holding the compiled core (7,361 Kotlin functions, 559 Objective-C classes) and the runtime (allocator, concurrent mark and sweep collector, safepoints) The figures for both apps, from the Release `iosArm64` framework and the Release app executable: | | Foyer | Memory Lane | | - | - | - | | Kotlin/Native object | 16.2 MB | 24.0 MB | | Kotlin functions defined in it (`kfun:` symbols) | 7,361 | 11,963 | | Collector, memory-manager and allocator symbols (`kotlin::gc::`, `kotlin::mm::`, `kotlin::alloc::`) | 54, 58, 96 | 54, 58, 96 | | Concurrent-mark symbols (`gc::mark::ConcurrentMark`) | 16 | 16 | | Objective-C classes defined in it | 559 | 852 | | SKIE's Swift object | 1.5 MB | 2.6 MB | | Swift symbols defined in it | 5,009 | 7,041 | | Kotlin functions defined in it | 0 | 0 | | Kotlin-defined Objective-C classes it references | 229 | 561 | | Release app executable | 6.2 MB | 31.3 MB | The object files are larger than the executable because they carry symbol tables and code the app never calls; the Release link drops both. Both executables contain the runtime's message `GC stop the world: concurrent mark took too long`, which is one way to confirm the collector shipped. ## How does Kotlin/Native manage memory? Kotlin objects live in one heap that every thread shares. The [Kotlin/Native memory manager](https://kotlinlang.org/docs/native-memory-manager.html) reclaims them with a tracing collector: * The collector is **concurrent mark and sweep**, with no generations. It runs on its own thread and starts a collection on memory-pressure heuristics or on a timer. * **Marking runs concurrently with the app's threads.** Each collection stops the app's Kotlin-running threads twice, briefly; the runtime logs each stop as a "mutators pause". * When marking takes too long, the runtime finishes it with the threads stopped and logs `GC stop the world: concurrent mark took too long`. When the heap grows past its target while a collection is running, the threads running Kotlin code wait until that collection finishes. ARC frees a Swift object when its last strong reference goes away, at that moment and on that thread; the collector does not trace Swift objects. ### What the collector costs on the main thread The runtime prints one block of lines per collection when the framework is built with its log option: ```kotlin src-kmp/apple-umbrella/build.gradle.kts theme={null} target.binaries.framework { // One log block per collection on stderr; measurement builds only. freeCompilerArgs += "-Xruntime-logs=gc=info" } ``` Each collection reports its two pauses as `Mutators pause time #1` and `#2`, measured from the moment the collector asks the threads to stop until they resume, plus the objects it marked and the heap before and after. The two pause times added together are how long one collection holds the main thread. `Time to pause` is the part of a pause spent waiting for every thread to reach a stopping point. **Foyer, a scripted 100-second walk** on the iPhone 17 Pro simulator (iOS 26.5): guest sign-in, 55 taps on the buttons on screen, 14 deep links and swipes. Foyer allocates little, so its 10 collections came from the timer, one every 10.0 s, with about 970 live objects. The pauses of a collection added up to a median of 49 µs and at most 241 µs. **A SwiftUI list scrolling at 60 frames per second on an iPhone 15 Pro (iOS 26.6.1)**, while the main thread creates Kotlin objects from Swift, far more than Foyer does, to show where the collector starts to cost frames. Each row is three 30-second runs of a Release build, timed by the app's own display link: | Allocation on the main thread | Collections | Pauses per collection | Dropped frames | | - | - | - | - | | 300,000 Kotlin objects per second | 568, one every 0.16 s | median 78 µs; 99% under 3.1 ms; longest 13.7 ms | 1 of 5,399 (a 33 ms frame) | | 1,200,000 Kotlin objects per second | 2,295, one every 0.04 s | median 19 µs; 99% under 0.07 ms; longest 7.7 ms | 1 of 5,400 (a 25 ms frame, outside any collection) | | 300,000 Kotlin objects per second, collector disabled (`kotlin.native.binary.gc=noop`) | 0 | — | 0 of 5,400 | | The same scroll with no Kotlin objects | — | — | 1 of 5,400 (a 25 ms frame) | No pause lasted a full frame (16.7 ms), and the Kotlin runs dropped as many frames as the scroll with no Kotlin objects did. In the longest pause, 10.3 of the 13.7 ms was the wait for a thread to reach it. The collector-disabled build did not finish a run at 1,200,000 objects per second: it held 2.37 GiB when iOS logged a low-memory event, and it exited before its 30 seconds ended. **Recording with Instruments changes the result.** With the Animation Hitches template recording, the same 300,000-objects-per-second runs dropped 15 frames in 5,301, and the longest frame lasted 869 ms; the runs with no Kotlin objects and with the collector disabled dropped none by the app's own timing. During the 869 ms frame the main thread was inside `+[KotlinBase allocWithZone:]`, in the allocator's page lookup, where it yielded the CPU 43,147 times, and the collector thread was blocked; the collection's two logged pauses added up to 40 µs. When a hitch trace shows a long frame inside a Kotlin allocation, run the same scenario without the recording and time the frames in the app before attributing the frame to the collector. To see collections in Instruments next to your own intervals, also set `kotlin.native.binary.enableSafepointSignposts=true` in `gradle.properties`. ## What does SKIE add? The Kotlin/Native compiler exports the core through an Objective-C header. [SKIE](https://skie.touchlab.co/) adds Swift source on top of that header, and Swift's compiler builds it into the framework's second object. SKIE generates wrappers only: the Kotlin runs as Kotlin/Native compiled it, and SKIE's Swift calls the Objective-C classes. Foyer's Release build generates 127 Swift files, under `src-kmp/apple-umbrella/build/skie/binaries/releaseFramework/RELEASE/iosArm64/swift/generated/`. Its `StateFlow` wrapper shows the pattern. The Swift class holds the Kotlin flow and reads through to it: ```swift Skie/Skie.SkieSwiftStateFlow.swift (generated by SKIE 0.10.14) theme={null} public final class SkieSwiftStateFlow : FoyerKit.SkieSwiftFlowProtocol, Swift._ObjectiveCBridgeable { @_spi(SKIE) public let delegate: FoyerKit.Kotlinx_coroutines_coreStateFlow public var value: T { delegate.value as! T } // …bridging and makeAsyncIterator() } ``` Holding a `SkieSwiftStateFlow` therefore holds the Kotlin `StateFlow`. Iterating it with `for await` creates a `SkieSwiftFlowIterator`, which starts a Kotlin collection and cancels it in its `deinit`. What crosses as what: | Kotlin declaration | What Swift sees | | - | - | | A sealed interface or class | An Objective-C protocol or class for each type in the hierarchy, plus `onEnum(of:)`, which returns a Swift enum to `switch` over exhaustively | | `StateFlow` returned by a top-level function | `SkieSwiftStateFlow`: `.value`, and an `AsyncSequence` of `T` | | A `StateFlow` declared on an exported interface | The raw `Kotlinx_coroutines_coreStateFlow`, with no element type ([Tutorial 4](/tutorials/duet-04-workers#add-the-two-streams-to-the-ports) re-exposes such streams as top-level functions) | | A `suspend` function | An `async` Swift function | | A Kotlin class | An Objective-C class with the module's prefix, such as `FoyerKitSplashState`, named `SplashState` in Swift | ## What happens when Swift holds a Kotlin object? Swift holds a Kotlin object through its Objective-C class, so ARC counts the references. The Kotlin runtime treats every object with a nonzero count as a root: the collector does not free it while Swift holds it. After the last Swift reference is released, the object is freed **on a later collection**, not at the release. The Kotlin page on [ARC integration](https://kotlinlang.org/docs/native-arc-integration.html#object-reclamation) states it: "An object is reclaimed only during the garbage collection." Nothing in the Kotlin object runs when Swift lets go of it. Two panels. Swift objects: SplashViewShell holds BridgedStore; LiveSplashEnvironment holds the onDelegate closure, which points back to SplashViewShell through a weak capture. Kotlin objects: the Kotlin Store holds its StateFlow. BridgedStore holds the Kotlin Store through an ARC reference that is a GC root. The Kotlin Store retains LiveSplashEnvironment through objc_retain; the finalizer releases it on the main thread. Two panels. Swift objects: SplashViewShell holds BridgedStore; LiveSplashEnvironment holds the onDelegate closure, which points back to SplashViewShell through a weak capture. Kotlin objects: the Kotlin Store holds its StateFlow. BridgedStore holds the Kotlin Store through an ARC reference that is a GC root. The Kotlin Store retains LiveSplashEnvironment through objc_retain; the finalizer releases it on the main thread. Measured with Foyer's splash store in a test program on macOS (the harness in [the cycle section](#why-does-a-cycle-across-the-boundary-leak-and-how-do-you-avoid-it)): after the last Swift reference to the store was released, the store was freed 0.30 s later while the program allocated Kotlin objects, and 10.08 s later while it allocated nothing, when the collector's timer fired. ## What happens when Kotlin holds a Swift object? A Duet feature hands Swift objects to its Kotlin store: the live environment (`LiveSplashEnvironment` in Foyer), each port implemented in Swift, and the closures they carry. The Kotlin object that holds one keeps it alive with an Objective-C retain. The Swift object is released after the collector frees its Kotlin holder. The runtime's finalizer does the release, and the Kotlin page on [deinitializers](https://kotlinlang.org/docs/native-arc-integration.html#deinitializers) states where: on the main thread for objects passed to Kotlin on the main thread, and on a collector thread otherwise. The binary option `kotlin.native.binary.objcDisposeOnMain=false` moves every release to the collector thread. Because a Swift `deinit` may call back into Kotlin, it runs after the collection ends. A chain in which Kotlin and Swift objects alternate is therefore freed over several collections: the Kotlin page's example, Kotlin → Swift → Kotlin → Swift, takes two. In the same test program, the environment's `deinit` ran on the main thread 0.30 s after its store's last Swift release while the program allocated, and 10.08 s after it while idle. Code in the `deinit` of a Swift object you hand to Kotlin runs that long after the screen that created the object closed. ## Why does a cycle across the boundary leak, and how do you avoid it? ARC frees a Swift cycle only when you break it with a `weak` or `unowned` reference. The collector frees a cycle of Kotlin objects on its own. A cycle that contains objects of both kinds is never freed: ARC does not see the Kotlin references, and the collector treats the Swift-held Kotlin object as a root. The Kotlin page on [retain cycles](https://kotlinlang.org/docs/native-arc-integration.html#retain-cycles): "if at least one Objective-C object is present, the retain cycle of a whole graph of objects cannot be reclaimed". The cycle a Duet shell can create: an object owns a Kotlin store, the store holds its environment, and the environment's delegate closure captures the owner strongly. ```swift theme={null} final class Owner { var store: AnyObject? func handle(_ event: SplashDelegateEvent) {} } let owner = Owner() // Leaks: the closure holds `owner`, which holds the store, which holds the environment and its closure. let environment = LiveSplashEnvironment { event in owner.handle(event) } owner.store = makeSplashStore(environment: environment, scope: mainImmediateStoreScope()) ``` The fix is a weak capture in every closure that crosses into Kotlin: ```swift theme={null} let environment = LiveSplashEnvironment { [weak owner] event in owner?.handle(event) } ``` The test program builds both owners, releases them, and allocates 4 million Kotlin objects: | Owner | Owner `deinit` | Environment `deinit` | | - | - | - | | Weak capture | at the release | 0.30 s later, on the main thread | | Strong capture | never | never | **What the memory graph shows.** `leaks`, the command-line reader for the memory graphs Xcode exports, reports `0 leaks for 0 total leaked bytes` for the strong-capture owner, and lists the owner and its environment as live objects. The scanner cannot see the cycle: it scans the Kotlin heap as a root, so every Swift object the cycle holds is reachable from it. Find the cycle from the object that should be gone instead: 1. Close the screen, wait 10 seconds for a collection, and export the memory graph (**File ▸ Export Memory Graph** in Xcode's memory graph debugger). 2. Find an instance of the owner type that should have been freed. 3. Trace what holds it: `leaks --traceTree=
.memgraph`. A path that runs through `Region Memory Tag 246` goes through the Kotlin heap. 246 is the tag the Kotlin/Native allocator gives its memory unless the `mmapTag` binary option changes it. Duet's emitted code captures weakly in the closures it hands to Kotlin. `BridgedStore`'s collector task is `Task { [weak self] in … }`, and a parent shell routes a child's delegate events with `mounter.mountWelcome { [weak self] event in self?.store.send(…) }`. Keep both forms when you edit them. ## Why does a Duet shell stop its store explicitly? Releasing a Kotlin store does not stop it. A store whose effects are still running stays reachable from them: a pending timer, for example, is held by the dispatcher that will resume it. The collector does not free such a store, and its effects keep running. A Duet shell therefore stops the Kotlin store as a step of its own. Foyer's splash builder gives the bridge a teardown closure: ```swift src-ios/Libraries/FoyerKit/Sources/SplashShell/SplashBuilder.swift theme={null} let scope = mainImmediateStoreScope() let store = makeSplashStore( environment: LiveSplashEnvironment(onDelegate: onDelegate), scope: scope) let bridged = SplashKitStore( state: splashStateFlow(store: store), send: { store.send(action: $0) }, teardown: { store.teardown() cancelStoreScope(scope: scope) }) ``` `BridgedStore.cancel()` cancels the Swift collector task and calls that closure. The shell adopts the bridged store into its `StoreHost`, which calls `cancel()` when the shell deactivates, in reverse order of adoption. [Tutorial 2](/tutorials/duet-02-two-apps#write-the-swift-shell) writes this shell. ## Why does `BridgedStore` re-read the state after `send`? A Duet shell must observe a reduce before `send` returns: its view state and the child mounts follow from the new state in the same call. The Kotlin store reduces on the calling thread, because its scope runs on `Dispatchers.Main.immediate`. The bridged `StateFlow` delivers to Swift through `for await`, and every delivery crosses a continuation hop, so it arrives in a later main-actor turn. `BridgedStore.send` therefore forwards the action and then reads `stateFlow.value` in the same call. On each delivery its collector task re-reads `value` and discards the delivered element, because the bridged sequence buffers and can deliver a state older than the one `send` already mirrored. [Tutorial 2](/tutorials/duet-02-two-apps#write-the-consumer-package-and-the-store-mirror) writes the mirror. ## What happens to a Kotlin exception? A Kotlin exception that reaches Swift through a function without `@Throws` terminates the process. With `@Throws(SomeException::class)` on the Kotlin function, Swift sees a `throws` function and the exception arrives as a Swift `Error`. Foyer's boundary object annotates every exported function that can throw; [Tutorial 2](/tutorials/duet-02-two-apps#export-the-replay-boundary) shows it. The Kotlin page on [Swift/Objective-C interop](https://kotlinlang.org/docs/native-objc-interop.html#errors-and-exceptions) has the full rules. ## How is Android different? On Android the same Kotlin modules compile to JVM bytecode and run on ART, under ART's garbage collector. The Compose shell, the store and the workers are objects in one heap, and the Android app calls the store's `send` directly, with no generated layer and no retain/release at a boundary. The rules on this page apply to the iPhone app only. [Inside a Duet Android app](/articles/duet-android-app-anatomy) walks that tree. ## How do you check your own build? Run these against the Release framework of your app's iPhone slice. The paths are Foyer's; replace `FoyerKit` with your project's framework name. ```sh theme={null} cd src-kmp/apple-umbrella/build/bin/iosArm64/releaseFramework mkdir -p /tmp/kit && cp FoyerKit.framework/FoyerKit /tmp/kit/ && cd /tmp/kit ar -t FoyerKit # the two objects ar -x FoyerKit nm -m FoyerKit.framework.o | grep -v undefined | grep -c 'kfun:' # Kotlin functions nm FoyerKit.framework.o | c++filt | grep -c 'gc::mark::ConcurrentMark' # the collector nm -m FoyerKit.framework.o | grep -v undefined | grep -c '_OBJC_CLASS_\$_' # Objective-C classes nm -m FoyerKit.o | grep -v undefined | grep -c ' _\$s' # SKIE's Swift symbols nm -m FoyerKit.o | grep -v undefined | grep -c 'kfun:' # 0: no Kotlin in SKIE's object ``` Foyer prints: ```text theme={null} __.SYMDEF SORTED FoyerKit.framework.o FoyerKit.o 7361 16 559 5009 0 ``` A Debug framework lists more objects: one per generated Swift file and one per cached Kotlin library. Use the Release framework for these counts. To check for the collector in the app you ship, search the executable inside the archive: ```sh theme={null} strings -a "Foyer.xcarchive/Products/Applications/Foyer.app/Foyer" | grep 'concurrent mark took too long' ``` ## Common questions Yes, twice per collection, and briefly in the measurements on this page. On an iPhone 15 Pro scrolling a list while the main thread created 300,000 Kotlin objects per second, the pauses of a collection added up to a median of 78 µs and at most 13.7 ms, and 1 frame in 5,399 was dropped, as many as in the same scroll with no Kotlin objects. In a scripted Foyer walk on the simulator the longest was 241 µs. [What the collector costs on the main thread](#what-the-collector-costs-on-the-main-thread) has the method, the flag to measure your own app, and how recording with Instruments changed the result. Yes. `@State` holds a strong Swift reference, so the Kotlin object stays alive while the view does, and the collector frees it on a later collection after the view goes away. For a Kotlin value such as a state snapshot that is all you need. For a Kotlin store, use a shell and a `BridgedStore`: a store in `@State` is never stopped, and [a store with running effects is never freed](#why-does-a-duet-shell-stop-its-store-explicitly). SKIE adds code size, measured above as 1.5 MB in Foyer's framework and 2.6 MB in Memory Lane's before the link, and one Swift wrapper object per value it wraps: an `onEnum(of:)` result, a `SkieSwiftStateFlow`, an iterator per `for await`. Each delivery of a bridged flow crosses a continuation hop. This page has no CPU measurement of SKIE's wrappers. The Kotlin/Native compiler runs inside Gradle, which runs on the JVM. The app scheme's build pre-action runs `scripts/assemble_kit.sh`, which runs Gradle to compile the Kotlin core into the XCFramework before Xcode links it. The JDK is a build-machine requirement; the app contains no JVM. Yes. On the Swift flavor, each feature's reducer is Swift and the iPhone app links no Kotlin/Native framework, so none of this page applies to it. [The Duet overview](/articles/duet#the-two-duet-templates) compares the two flavors. [Duet Twins](/articles/duet-twins), in development, pairs a Swift core in the iPhone app with a Kotlin core in the Android app. ## Sources and further reading * [Kotlin/Native memory management](https://kotlinlang.org/docs/native-memory-manager.html): the collector, its triggers, the `gc` and `-Xruntime-logs` options and the safepoint signposts * [Integration with Swift/Objective-C ARC](https://kotlinlang.org/docs/native-arc-integration.html): reclamation timing, main-thread deinitialization, `objcDisposeOnMain` and retain cycles * [Interoperability with Swift/Objective-C](https://kotlinlang.org/docs/native-objc-interop.html): how Kotlin declarations map to Objective-C, and `@Throws` * [SKIE: Flows](https://skie.touchlab.co/features/flows), [Sealed classes](https://skie.touchlab.co/features/sealed) and [Suspend functions](https://skie.touchlab.co/features/suspend): the Swift layer SKIE generates * [Gathering information about memory use](https://developer.apple.com/documentation/xcode/gathering-information-about-memory-use): Xcode's memory graph debugger and `.memgraph` files * [Understanding hitches in your app](https://developer.apple.com/documentation/xcode/understanding-hitches-in-your-app): how Apple defines a hitch and measures it with Instruments * [Kotlin/Native GC scheduler, `MutatorAssists.hpp`](https://github.com/JetBrains/kotlin/blob/v2.4.10/kotlin-native/runtime/src/gcScheduler/common/cpp/MutatorAssists.hpp): why threads running Kotlin code wait for a running collection when the heap passes its target * [The duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials): `tutorial9-complete`, the Foyer tree measured on this page ## Read next Build the framework this page measures, the `BridgedStore` mirror, and the shell that stops the Kotlin store. Both stacks run the same Kotlin/Native runtime on iOS; they differ in who draws the screen. The same core on Android, where it shares one heap with the Compose shell. Shell, store, host, mount and the other terms on this page. # Working with Modaal: A Guide to Modes and Prompts Source: https://docs.modaal.dev/articles/modes-and-prompts When to turn the Plan toggle on for a feature spec, when to let the agent build directly, and the prompting techniques that improve the result in each mode. ## What do the two modes share, and when does the toggle apply? [Modaal](https://modaal.dev) is an AI system for building native iOS and Android apps. You describe an idea or a task, and the AI agent generates code, builds the project, analyzes errors, and iterates with you. The quality of the result depends directly on *how* you phrase your request and whether you ask for a plan first. There's a single control for that — the **Plan** toggle in the composer: * **Plan off** (the default) — the agent works directly: it writes code, builds the project, debugs, fixes, and iterates with you. * **Plan on** — the agent produces a structured implementation plan (a feature spec) **without writing code**. Both share the same project context, constitution, knowledge base, and tooling — toggle **Plan** on or off at any point. **On a brand-new project you don't touch this toggle at all.** Your project's structure is chosen by [the sentence on the home screen](/articles/new-project#the-new-project-flow) before the session starts, and the first turn runs in planning mode automatically — it writes a PRD and plans the first feature. Everything below applies from your second prompt onward. *** ## The Plan toggle You control planning with the **Plan** button next to the composer. Leave it **off** for most work; turn it **on** when you want a thorough plan before any code is written. Composer with the Plan toggle off Composer with the Plan toggle on | Plan toggle | When to Use | What the Agent Does | Writes Code? | | - | - | - | - | | **Off** (default) | Building, iterating, prototyping, and one-off actions | Implements features, builds, debugs, fixes, refactors — iterating with you | Yes | | **On** | Plan before building — complex features | Researches the codebase, writes a detailed spec | No | The two produce different deliverables. With **Plan** on, the deliverable is the plan itself — a feature spec at `specs//spec.md` — and no code is written yet. With **Plan** off, the deliverable is working code: the agent implements your request directly. If a spec already exists from a Plan-on session, the agent reads it and builds from it. ### How it fits together **Just created a project?** Your first turn already ran in planning mode — you have a `PRD.md` and a first feature spec waiting. Leave **Plan** off and say *"let's build this"* to start implementing. **Working on an existing project?** Two typical workflows: **Structured: Plan on → Plan off → repeat** ``` Plan on (spec) → Plan off (implement) → Plan on (next feature) → Plan off (implement) → ... ``` Use **Plan on** to research the codebase and produce a detailed feature spec, then turn **Plan off** to implement it. Repeat for each feature. **Exploratory: keep Plan off** ``` Plan off (build & iterate) → Plan on (when a feature needs a thorough spec) → Plan off ... ``` Keep **Plan off** for continuous building and iteration, turning **Plan on** when a feature is complex enough to warrant a thorough spec first. The agent picks up all context automatically when you toggle — active feature, constitution, knowledge base, attached files. *** ## With Plan on — Planning ### What it does With **Plan** on, the agent produces a thorough written plan **before** any code is written. It: * **Does not** edit, create, or modify any source code * Reads any file in the codebase for research * Produces a single output: `spec.md` inside the feature directory * Can continue or update an existing feature spec Planning has two flavors, and the agent **auto-detects** which applies — you don't pick: * **A freshly created project** → the **Refine** flow (below), which runs automatically on your first turn * **An established project** → researches your codebase and writes a feature spec referencing real files and patterns to reuse ### Refine: the first turn on a new project The first prompt on a freshly created project is the **Refine** step, and it runs in planning mode whether or not you ever touch the toggle. You come in with an idea — anything from a single sentence to a multi-page description with Figma links — and the agent turns it into a structured project. Refine has two phases: * **Phase A** — Understand the idea → confirm scope if it's ambiguous → write the PRD * **Phase B** — select the first feature from the PRD → create the feature directory → write its `spec.md` **What the agent does:** 1. Reads your description (text, images, Figma links — whatever you provide) 2. Researches the knowledge base for patterns matching your project's architecture and platform 3. Confirms scope with you if the input is ambiguous — core features vs. deferred 4. Writes a structured PRD to `PRD.md` 5. Plans the first implementable feature and writes `spec.md` **Refine never asks about architecture, platforms, or devices.** Those were settled by the template [the sentence](/articles/new-project#the-new-project-flow) picked, and the agent is told what was scaffolded. It won't re-scaffold or second-guess the structure either. Refine is also prototype-first by default: the first iteration aims at a working, clickable prototype with realistic mock data, deferring auth, backends, and analytics unless you ask for them. If you'd have reached for a "vibe-code" mode, this is it — you're already in it. After Refine completes, turn **Plan** off to start implementing the first feature. **Prompt examples (new project):** **A brief idea:** > A guitar tuner app. It listens to the microphone, detects the pitch, and shows whether the string is in tune. The agent expands this into a PRD: the main tuner screen, tuning selection (standard, drop-D, open-G), visual feedback (needle + color), session history (as a provisional suggestion). It notes that microphone access and audio analysis are core, while cloud sync is deferred. Then it writes a feature spec for the first iteration. **A detailed description:** > I'm building a local services marketplace. Here's what I need: > > **User side**: Browse services by category, view provider profiles with ratings, book appointments with date/time picker, in-app chat with provider, pay via Stripe, rate after service. > > **Provider side**: Create/edit profile, manage availability calendar, accept/reject bookings, view earnings dashboard. > > Target: iOS 18+, iPhone only. Need to support Ukrainian and English. Here the agent preserves everything as-is, adds structure (user flows, acceptance criteria, technical considerations), and doesn't invent extra features. **Figma-based:** > Here's my Figma design: \[link]. Build this app exactly as designed. The first tab is a feed, the second is search, the third is profile. The agent uses Figma MCP to pull design context and screenshots, then generates a PRD based on the UI structure. **Just an attachment:** You can attach a PRD, design doc, or screenshot with no text at all. The agent reads the attachment and works from its content. ### Planning on an existing project For an existing app, **Plan** on makes the agent research the codebase thoroughly, understand existing patterns, and produce a detailed feature spec — without writing any code. **How it works:** 1. **Parse User Input** — reads your feature description and checks for existing feature context 2. **Determine Feature Context** — decides whether to continue an existing feature or create a new one: * If the request refines/extends the current feature → updates the existing spec * If it describes a clearly different feature → creates a new feature directory * If ambiguous → asks you to choose 3. **Research the Codebase** — reads source files, identifies patterns, finds reusable code, checks the knowledge base 4. **Write the Spec** — creates or updates `spec.md` with: Overview, User Scenarios, Technical Approach (Architecture, Key Design Decisions, Existing Code to Reuse), Implementation Steps (phased), Files to Create/Modify, Risks & Open Questions, Testing Strategy 5. **Report Completion** — summarizes findings and flags open questions **Prompt examples (existing project):** **New feature for an existing app:** > Add push notification support. Users should be able to opt in/out per category (promotions, reminders, social). Store preferences locally and sync with the backend. Show an onboarding prompt on first launch. The agent explores the existing codebase, understands the current architecture and patterns, and produces a detailed spec referencing actual files and functions to reuse. **Updating an existing feature plan:** > The notification spec needs to also handle silent push for background data sync. Add a section covering the background modes capability and how it integrates with the existing sync service. The agent reads the existing `spec.md`, incorporates the new requirement, and overwrites with the revised version — preserving sections that are still valid. After the plan is ready, turn **Plan** off to implement. *** ## With Plan off — building directly This is the default. The agent executes your request directly — it writes code, builds the project, and iterates with you. Use it for anything that produces code: continuous feature-building, fast prototyping, and short, specific one-off tasks. With **Plan** off, the agent: * Generates code, builds the project, and iterates with you * Verifies its work (runs the project after changing Swift or Kotlin sources) * Suggests alternatives and improvements * Can ask you clarifying questions via structured prompts * Commits after completing a logical unit of work (asking you first) If a feature was already planned with **Plan** on, the agent reads that `spec.md` for context and implements from it. On its own, **Plan** off doesn't write a spec — it goes straight to the code. Working directly isn't "sloppy." The agent still follows the project's architectural rules (Duet, RIBs or MVVM), uses semantic design tokens, and structures modules correctly. It just doesn't write a formal plan before it starts. ### How it works **Plan** off handles two kinds of request, and the agent picks the right shape per turn: * **Execute** — an imperative request (*"build the project"*, *"fix the crash in ProfileViewModel.swift:42"*, *"extract this into a helper"*). The agent does the work, verifies it (runs the project if Swift or Kotlin sources changed), and reports what changed with file/line citations. * **Answer** — a question (*"why is the build failing?"*, *"what does this function do?"*, *"where is the device list populated?"*). The agent reads the relevant files and answers directly, with citations — it doesn't start editing code just to answer. It does what was asked and stops — no unrelated cleanups, no speculative next steps. When a change adds up to a logical unit of work, it asks whether to commit. If a feature was already planned with **Plan** on, the agent reads that `spec.md` and implements from it — thorough planning followed by fast execution. ### When to use it * You're validating an idea and want to see results fast * You want a clickable prototype quickly * Requirements aren't fully clear yet — you want to iterate * You're implementing a feature that was already planned with Plan on * You need a one-off action: a build, a debug session, a specific fix, a refactor **Want a quick clickable prototype?** There's no separate "prototype" mode — just say so. Describe what you want with **Plan** off and iterate, or turn **Plan** on for a prototype-focused plan first (UI + realistic mock data, integrations deferred), then build it. ### Prompts for building directly **Starting from scratch:** > I want to build a habit tracker. The main screen shows today's habits as a checklist. Tapping a habit marks it done with a satisfying animation. There's a stats screen showing streaks. The agent starts with the main screen, then adds navigation and the statistics screen. After each step — build, verify, next step. **Implementing a planned feature:** > Let's implement this. Start with the notification preferences screen. The agent reads the spec from your Plan-on session, picks up the first implementation step, and starts building. **Iterating on what exists:** > The habit list looks too plain. Let's add a card-based layout — each habit is a rounded card with the habit icon on the left, name in the center, and a circular progress indicator on the right. The agent understands the context (which view to change), updates the layout, builds, and shows the result. **Changing direction:** > Actually, let's pivot. Instead of a checklist, make it a ring-based UI like Apple's Activity rings. Each habit is a ring, and filling it gives visual feedback. The agent handles pivots smoothly — generates the new UI and preserves what didn't change (models, services). **Technical request:** > Add CloudKit sync. When a habit is completed on one device, it should appear on other devices within a few seconds. Use CKSubscription for real-time updates. You can give precise technical instructions. The agent doesn't simplify — it follows your level of detail. **Build:** > Build the project. or > Build only the watchOS target. **Build debugging:** > The build is failing with "Cannot find type 'ThemeProviding' in scope" in TunerView\.swift. Fix it. Give the agent the full error. It analyzes imports, module dependencies, and suggests a fix. **Runtime debugging:** > The app crashes on launch. The crash log shows EXC\_BAD\_ACCESS in SceneDelegate.swift line 14. The agent checks crash context, runtime logs, and suggests a fix. **Precise edit:** > In MainView\.swift, change the navigation title from "Home" to use the localized string .mainScreenTitle instead. **Refactoring:** > Extract the booking flow (BookingDateView, BookingConfirmView, BookingPaymentView) into a separate SPM module called BookingFeature under Libraries/. *** ## What each mode writes to the feature directory Turning **Plan** on produces a written artifact — a feature spec — that **Plan** off then implements: ```text theme={null} specs/ └── 001-feature-name/ └── spec.md ← The feature specification (written with Plan on) ``` What the spec contains depends on how you planned it: | How it was written | spec.md Content | | - | - | | **Plan on** (existing app) | Overview, User Scenarios, Technical Approach (Architecture, Existing Code to Reuse), Implementation Steps, Files to Modify, Risks, Testing | | **Refine** (new project, first turn) | Same structure, scoped to the first iteration from the PRD. Refine also writes `PRD.md` | **Plan** off doesn't create a spec of its own — it builds directly. But when a spec from a Plan-on session exists, **Plan** off reads it and implements from it. That's the structured workflow: plan with **Plan** on, build with **Plan** off, against the same feature directory. *** ## How six sessions went, start to finish ### Case 1. "Build Me an App" — From One Sentence to a Prototype **Situation:** You have an idea and want to see results as fast as possible. No specs, no requirements — just a sentence. **Approach:** Refine (the first turn) → Plan off **Step 1 — start the project** with this in the composer, with the sentence set to **I'm building a Quick prototype**: > A meal planning app. You pick recipes for the week, and it generates a shopping list. The agent generates a PRD: weekly planner screen (calendar grid), recipe catalog (with mock data), auto-generated shopping list grouped by category. Immediately marks as deferred: backend, auth, sharing. Then it writes a feature spec for the first iteration. **Step 2 (Plan off):** > Let's build this. Start with the weekly planner screen — a horizontal scroll of 7 day cards. Tapping a day opens a sheet to add recipes. Then you iterate: > The day cards are too small on iPhone SE. Make them fill the width and stack vertically instead — a scrollable list of 7 rows. > Add a "Generate Shopping List" button at the bottom of the planner. It should aggregate all ingredients from selected recipes, merge duplicates (2 onions + 1 onion = 3 onions), and group by supermarket aisle. > The shopping list looks good but needs a "check off" feature — tapping an item strikes it through. From one sentence to a working prototype — a few hours. ### Case 2. Complex UI from Figma **Situation:** A designer created a detailed mockup in Figma. You need to translate it to code pixel-perfect. Multiple screens, custom components, animations. **Approach:** Plan off (with Figma MCP) > Here's the Figma design for the onboarding flow: [https://figma.com/design/abc123/Onboarding?node-id=1-2](https://figma.com/design/abc123/Onboarding?node-id=1-2) > > Build all 4 onboarding screens exactly as designed. Each screen has a full-bleed illustration, title, subtitle, and a "Continue" button. The last screen has "Get Started" instead. Add a page indicator at the bottom. Swipe gesture between screens. The agent pulls design context via Figma MCP (colors, fonts, spacing, layout), generates SwiftUI views with the closest matching semantic tokens, and builds the project. Iteration: > The illustration on screen 2 is clipped at the bottom on smaller devices. Make it aspect-fit instead of aspect-fill, and add 16pt bottom padding before the title. > Add a subtle parallax effect to the illustrations — as the user swipes, the illustration on the next screen should slide in slightly faster than the text. ### Case 3. Adding a Feature to an Existing App **Situation:** You have a working app and want to add a complex feature. You want the agent to understand the existing code before proposing changes. **Approach:** Plan on → Plan off **Step 1 (Plan on):** > Add push notification support. Users should be able to opt in/out per category (promotions, reminders, social). Store preferences locally and sync with the backend. Show an onboarding prompt on first launch. The agent researches the codebase, identifies the existing architecture and patterns, and produces a detailed `spec.md` referencing actual files, functions, and modules to reuse. **Step 2 (Plan off):** > Let's implement this. Start with the notification preferences screen. The agent reads the spec it just wrote, picks up the first implementation step, and starts building. ### Case 4. Technical Feature: Camera + CoreML **Situation:** You need to add object detection via the camera using CoreML. This is a UIKit-heavy feature (AVCaptureSession) that integrates into a SwiftUI wrapper. **Approach:** Plan off, with technical detail > Add a camera scanner feature to the app. Requirements: > > 1. Use AVCaptureSession with the back camera, preview in a full-screen layer > 2. Run a CoreML model (YOLOv8 — I'll add the .mlmodel file later, use a placeholder for now) on every nth frame > 3. Overlay bounding boxes on detected objects with labels and confidence scores > 4. In RIBs: create a ScannerRIB under Main. The interactor manages the capture session lifecycle. The view is a UIViewController with AVCaptureVideoPreviewLayer, not a SwiftUI view. > 5. Add a "Scan" button on the main screen that attaches the ScannerRIB. Notice: the prompt tells the agent *which* architecture to use (RIBs here — in a Duet or MVVM project, name that architecture instead), *which* system frameworks (AVFoundation, CoreML, Vision), and *where* to place the code. The more technical detail you give, the more precise the result. Iteration: > The bounding box overlay flickers. The issue is likely that we're updating UI on a background thread. Dispatch the overlay updates to the main queue. Also, add a confidence threshold — only show boxes with confidence > 0.7. ### Case 5. "Fix My Build" — A Debug Session **Situation:** After a refactoring, the project stopped building. Lots of errors, and you don't want to dig through them yourself. **Approach:** Plan off (build + debug). A bug you can see on the screen rather than in the build log takes the same mode — see [Debug your app by pointing at the screen](/articles/visual-debugging). **Step 1:** > Build the project. The agent runs the build, gets the error log, analyzes it. **Step 2** (the agent responds with analysis, you provide context): > Fix all the build errors. The refactoring was moving SettingsInteractor from the Main module to a new SettingsFeature module — some imports and dependency wiring are probably broken. Context about *what* you refactored helps the agent understand the root cause instead of just patching symptoms. **Step 3** (if there are still errors after the fix): > Build again. If there are still errors, fix them too. An iterative build-fix cycle is a normal pattern. The agent keeps context between iterations. ### Case 6. Prototype Evolution: From Quick Builds to Structure **Situation:** You started with Plan off, built a prototype, and it grew to 12 screens. Now you want to add a complex feature (subscription with paywall, trial period, purchase restoration) and feel you need more structure. **Approach:** Plan on → Plan off (or a detailed Plan-off prompt) You can turn Plan on to get a thorough spec first, or give a spec-level prompt directly with Plan off: > Add a subscription system with these requirements: > > 1. **Paywall screen**: Shown when user tries to access premium features. Two plans: Monthly ($4.99) and Annual ($39.99). Highlight annual as "Best Value". Show feature comparison table. > 2. **Purchase flow**: Use StoreKit 2. Handle: successful purchase, cancelled, failed, pending (Ask to Buy). Show appropriate UI for each state. > 3. **Trial**: 7-day free trial for annual plan. Show trial badge on paywall. When trial ends, auto-convert to paid (standard StoreKit behavior). > 4. **Restore**: "Restore Purchases" button on paywall and in Settings. Verify receipt server-side (mock the server call for now, return success). > 5. **Premium gate**: Create a PremiumAccessService that checks subscription status. Inject via @Environment. Premium features: unlimited habits (free = 5), custom icons, stats export. > 6. **Entitlement check**: On launch, verify current entitlements via StoreKit 2 Transaction.currentEntitlements. Cache status locally for offline access. This is a Plan-off prompt, but with planning-level detail. The agent implements step by step, building after each step. *** ## How to get a better result from a prompt ### Detail Level Drives the Result | Prompt | What You Get | | - | - | | `"Add a settings screen"` | A basic Settings with typical iOS patterns (toggles, about, version) | | `"Add a settings screen with: dark mode toggle, notification preferences (time picker for reminder), language selector (UA/EN), clear cache button with confirmation alert"` | Exactly what you described | | `"Add a settings screen. The dark mode toggle should write to UserDefaults key 'preferredAppearance' and immediately apply via overrideUserInterfaceStyle on the root window."` | Exactly what you described, with the specific implementation detail | All three are valid prompts. The difference is how much you control the result. ### Structure Complex Requests For features with multiple components, use numbered lists. The agent treats them as checklists — implements item by item and marks them done. ### Attach Files for Context You can attach files (images, documents, PDFs) to any prompt. The agent reads and incorporates them — useful for design references (see [designing with a design system](/ai-design), and [design prompts that work](/design-prompts) for the wording that gets a usable result), PRDs from other tools, or API documentation. You can even send *just* an attachment with no text, and the agent will interpret it. ### Give Context When Debugging Bad: > "The app crashes." Good: > "The app crashes when I tap the 'Save' button on the edit profile screen. Console shows: 'Fatal error: Unexpectedly found nil while unwrapping an Optional value' in ProfileViewModel.swift." ### Reference Specific Files > "In `BookingView.swift`, the date picker is showing dates in the past. Add a `minimumDate` constraint set to today." Naming the file, the element, and the change means the agent doesn't have to search for any of them. ### Pivoting Is Normal With Plan off you can change direction at any time. The agent adapts on the spot. You don't need to start a new project — just say: > "Scrap the tab-based navigation. Let's switch to a sidebar layout — the sidebar shows categories, and the main area shows the selected category's content." ### Toggling Plan Is Free Plan on and Plan off share the same project context. The agent picks up all context automatically — active feature, constitution, knowledge base. *** ## Summary: Plan on or Plan off? ```text theme={null} Have an idea, want to see results? │ ├── Brand new project │ └── Complete the sentence, describe the app → first turn runs Refine │ (PRD + first spec, automatically) → then a workflow below │ ├── Structured workflow (plan each feature, then build) │ └── Plan on → Plan off → Plan on → Plan off → ... │ ├── Exploratory workflow (build and iterate freely) │ └── Plan off (turn Plan on when a feature needs a thorough spec) │ └── Need a specific action (build, fix, refactor) └── Plan off ``` Turn **Plan** on to plan before building — it specs out a feature without touching code. Leave **Plan** off (the default) to build, iterate, and handle one-off actions. Toggle freely — the context follows you. New projects bootstrap themselves: [the new-project sentence](/articles/new-project#the-new-project-flow) sets up the structure, and the first turn runs **Refine** on its own. The practice the toggle implements is spec-driven development. [Spec-driven mobile app development](https://modaal.dev/blog/spec-driven-mobile-app-development) sets out the method on its own terms, without reference to a particular tool. *** *Modaal uses the same knowledge base, constitution, and architectural templates either way. The difference isn't in code quality — it's in how much planning happens before the code gets written.* # Starting a New Project and Choosing the Right Template Source: https://docs.modaal.dev/articles/new-project How the new-project flow works, what each template scaffolds, and how to choose between an iOS + Android app, a prototype, an iPhone app and a 2D game. Every Modaal project starts from a **template**. The template determines what is created on disk before the agent's first turn: which targets exist, which platforms they run on, and — most consequentially — which **architecture** your code is written against. This article covers both halves of that decision: the sentence you pick the template with, and how to choose the one that fits what you're building. *** ## The new-project flow You start a project by completing one sentence above the composer, describing the app in the composer, and pressing **Start new project**. There is no second step: nothing else is asked, and the name, the identifiers and the device families are derived from your description. ### 1. Complete the sentence The sentence reads *I'm building a `kind` for `platform`*, with an **+ Apple Watch** chip beside it. Each word is a button that opens a menu. The Modaal home screen under the heading 'Craft native': the sentence 'I'm building a Production app for Android + iOS' with a dashed '+ Apple Watch' chip, above a composer with the placeholder '…that lets people — describe it in your own words', a paperclip, a model selector reading 'Default (recommended)', the Start new project button, and the caption 'You can add features and targets later in chat. How templates work' **Word 1 — what you're building.** Three rows: | Kind | The line under it | | - | - | | **Quick prototype** | Test an idea fast | | **Production app** | Built to ship, feature by feature | | **2D game** | Build something playful | The kind menu open under the first word of the sentence: Quick prototype (Test an idea fast), Production app (Built to ship, feature by feature, checked), 2D game (Build something playful), and a How templates work link below a divider **Word 2 — where it runs.** It appears for **Production app** only — **Quick prototype** and **2D game** each have one template, so the sentence ends after the first word. Two rows: | Platform | The line under it | | - | - | | **iOS** | iPhone now, Android later | | **Android + iOS** | One codebase, both stores | The platform menu open under the second word of the sentence: iOS (iPhone now, Android later), Android + iOS (One codebase, both stores, checked), a checkbox row Add Apple Watch companion, and a How templates work link below a divider **Word 3 — the Apple Watch chip.** Also **Production app** only. Unselected it is a dashed **+ Apple Watch** chip beside word 2; clicking it fills it in and it reads **Apple Watch**, and clicking again clears it. The **Add Apple Watch companion** checkbox in the platform menu is the same setting — two places to set one value. Switching the kind away from **Production app** hides the chip and does not send it; switching back restores what it said. **Android + iOS is part of Pro** ([plans on modaal.dev](https://modaal.dev/pricing)). On a plan without it the row still selects and the sentence reads it, with a **Pro** chip and the monthly price on the row; **Start new project** then opens the upgrade screen instead of creating the project, and your draft and your selection are kept. The sentence opens on **Production app for iOS** the first time, on every plan. After that it opens on the combination you last created a project with. Both menus end with **How templates work ↗**, which opens this page. ### 2. Describe the app in the composer Type your idea into the composer — the placeholder continues the sentence: *"…that lets people — describe it in your own words"*. The paperclip attaches source materials: a PRD, a screenshot, a design doc, a Figma link. The selector beside the send button picks the agent and model that will do the work. Your description is used three ways: it names the app, it sets the device families (name iPad in it and the project is created for iPhone and iPad), and the agent writes the PRD from it on the first turn. Nothing is created while you type, and nothing is created if you never press the button — no project record, no folder, no git repository. ### 3. Press Start new project The name comes from your description; when nothing can be read from it, the project is created as **My App** and you can rename it in the **Project Settings** tab. The organization identifier comes from the one you last created with, or is suggested from your account, and the bundle identifier is derived from both — see [The name and identifiers](#how-the-three-fields-fit-together). A folder, a project record, and a git repository, all under that name. Targets, SPM modules, `xcodegen.yml`, build configurations, an app icon, and a passing test suite. Any files you attached are copied into the project. The agent receives it with Plan mode on, and continues from there. You wait on the home screen while that runs. The composer holds your text and goes read-only, the sentence stops responding, the send button reads **Setting up…**, and the line under the composer names the step — **Naming your app…**, then **Scaffolding**, with the name of the template being written. A **Cancel** button sits beside it until scaffolding starts — after that the project exists, so there is nothing left to cancel. If the create fails, the home screen shows the error and keeps both your draft and your selection. ### 4. The first turn: PRD and first feature spec The sentence has already answered the structural questions, so the agent's first turn starts at product scope. It: 1. Reads your description (and any attachments — images, PDFs, Figma links). 2. Writes a structured **`PRD.md`** at the project root. 3. Plans a workable **first iteration** and writes it to `specs/001-/spec.md`. The architecture and the target layout are already recorded in `.modaal/project.json`, so that turn neither asks which architecture to use nor re-scaffolds. It may ask a product question if your scope is ambiguous. When the plan looks right, turn **Plan** off and say *"let's build this"*. See [Modes and prompts](/articles/modes-and-prompts) for how the Plan toggle works from there on. ### The name and identifiers Three values are set at creation without asking you. Two of them are one chain — the product name and the organization identifier are the inputs, the bundle identifier is the output: ```text theme={null} Product name: Test App Organization identifier: com.test.modaal Bundle identifier: com.test.modaal.TestApp ``` * **Product name** is the user-facing field: the display name under the icon, the default name of the App Store listing, and the name of the project folder Modaal creates. It is taken from your description, and editable afterwards in the **Project Settings** tab. * **Organization identifier** is an internal naming convention and nothing more. Modaal reuses the one your last project was created with, or suggests one from your account. The custom is to take a domain you control and write it back to front: a personal site `janedoe.dev` gives `dev.janedoe`, a company site `acme.com` gives `com.acme`. Following the custom keeps your bundle identifiers unique without any registry, because nobody else owns that domain. Nothing verifies it, though: no check confirms the domain exists or that you own it, the string is never shown to users, and it is not registered with Apple or Google on its own. Its only consequence is the prefix it contributes to the bundle identifier. * **Bundle identifier** is the field with external consequences, derived as `.`. iOS uses it to tell apps apart on a device, and App Store Connect uses it to tie every uploaded build to one app record, so it must be unique across the App Store. Wherever the app is registered — the Apple Developer portal, App Store Connect, TestFlight, Firebase — it is the bundle identifier that is registered, never the organization identifier or the product name. **Check the bundle identifier before your first upload.** Once a build reaches TestFlight or the App Store, its Bundle ID can never be changed — you'd have to ship a new app. Set it in the **Project Settings** tab of the sidebar. Devices and orientations are set from template defaults, plus any device family your description names. Both are editable afterwards in the **Project Settings** tab, together with the name, the display name, the bundle identifier, the version, the minimum deployment target and the app icon. *** ## The templates at a glance The sentence has six combinations, and each one names a template: | Kind | Platform | Apple Watch | Best for | Targets | Architecture | | - | - | - | - | - | - | | **Production app** | **iOS** | off | iPhone-first products that expect Android | iOS app | Duet (Swift) | | **Production app** | **iOS** | on | the same, with a watch companion | iOS app + watchOS app | Duet (Swift) | | **Production app** | **Android + iOS** | off | products that ship on both stores | iOS app + Android app | Duet (Kotlin Multiplatform) | | **Production app** | **Android + iOS** | on | the same, with a watch companion | iOS app + Android app + watchOS app | Duet (Kotlin Multiplatform) | | **Quick prototype** | — | — | trying an idea, demos, experiments | iOS app | MVVM | | **2D game** | — | — | games, animated lessons, and screens drawn from sprites rather than views | iOS app | RIBs + SpriteKit | Some things every combination gives you: [XcodeGen](https://github.com/yonaskolb/XcodeGen)-generated project files, `.xcconfig`-based build configurations (Debug / AdHoc / AppStore), an Icon Composer app icon, a localization catalog (`Localizable.xcstrings` + SwiftGen), a theming module with semantic design tokens, and a test suite that passes from the first build (Swift Testing in **Quick prototype**, XCTest in **2D game**, scenario tests and recorded fixtures in the two **Production app** combinations). **Four templates sit outside the sentence** and are not creation paths: the CombineRIBs **Production app** template (`ios-ribs`), the two Apple Watch templates (`watchos`, `watchos-ios-ribs`) and the empty project (`empty`). Projects already created from them are fully supported, keep building, and keep getting features — see [CombineRIBs projects](#why-production-app-is-no-longer-offered), and [What each template scaffolds](#what-each-template-scaffolds) for the two watch templates. A workspace with no app targets is what an imported repository is; ask the agent to add targets to it in chat. *** ## What each template scaffolds A native iPhone app and a native Android app in one repository, with each feature's logic written once as a pure Kotlin reducer that both apps consume. ```text theme={null} src-kmp/ # the shared Kotlin core ├── / # one module per feature: State, Action, Effect, reducer ├── services/ # cross-cutting ports, log vocabulary, consent state ├── telemetry/ # the closed analytics event grammar ├── theming/ # design tokens, shared by both apps ├── apple-umbrella/ # one Kotlin/Native framework aggregating every feature ├── replay-runner/ # replays fixtures on the JVM └── app/ # the Android app: Compose shell, workers, Activity edge src-ios/ ├── App/ # the iPhone app target; xcodegen.yml is the source └── Libraries/ # Kit (links the framework), Main (composition root), # Theming, ThemedControls parity/ ├── manifest.yaml # the declared features, fixtures and generators ├── authoring.md # this repo's own front door for feature work ├── feature-specs/ # one one-pager per feature └── fixtures/ # recorded behavior — build products, never hand-edited tools/duet # the CLI that records and verifies ``` **How you'll write features:** a feature spec, then a `Given`/`When`/`Then` scenario, then `tools/duet record` to compile it into fixtures, then the reducer until the scenario passes, then a SwiftUI screen and a Compose screen that read state and send actions. `tools/duet verify` replays every fixture on both platforms and compares byte-for-byte; the same gate runs in CI on every push. The iPhone app consumes the *same compiled Kotlin logic* as the Android app, so there is no second implementation of a reducer to keep in step. See [Duet](/duet) for the full picture and [Handling platform-specific UI](/articles/cross-platform-ui-parity) for where the shared/native line falls. The [Duet tutorials](/tutorials/duet) write by hand what this combination scaffolds, one feature at a time, so you can read an emitted project and know what each file is for. One iPhone app, pure SwiftPM — no Kotlin, no Gradle, no Android SDK needed to build or ship it. Each feature is a pair of Swift packages. ```text theme={null} src-ios/ ├── Subtrees// # one directory per feature: │ ├── Feature/ # the reducer, its types, the worker, the scenarios │ └── Node/ # the builder and the SwiftUI shell ├── App/ # the iPhone app target └── Libraries/ # Main (composition root), Theming, ThemedControls parity/ ├── manifest.yaml ├── authoring.md ├── feature-specs/ # the porting source of truth └── fixtures/ # recorded behavior — build products, never hand-edited tools/duet ``` **How you'll write features:** the same loop as the Kotlin Multiplatform flavor, with the reducer in Swift and one shell instead of two. Every feature is recorded and specced exactly as in the two-platform flavor. That corpus is what makes Android a **per-feature, fixture-gated migration** later — the Kotlin core and Android shell are added to this same repository, and each feature's Kotlin twin is verified against fixtures recorded when the feature was first built. The iPhone app keeps building and shipping throughout. Pure SwiftUI, with the smallest module structure that still keeps the ViewModels unit-testable. ```text theme={null} src-ios/ ├── App/ │ ├── MyApp/ # @main App entry, Info.plist, app icon, assets │ ├── xcconfig/ # Base / Debug / AdHoc / AppStore │ └── xcodegen.yml ├── Libraries/ │ ├── MyAppMain/ │ │ ├── App/AppRootView.swift │ │ ├── Navigation/Route.swift # NavigationStack routes │ │ ├── Features/Main/MainView.swift │ │ ├── Features/Main/MainViewModel.swift │ │ └── Localizable.xcstrings │ └── Theming/ # semantic design tokens └── SharedLibraries/ ├── CombineExtensions/ └── SharedUtility/ ``` **How you'll write features:** one folder per feature under `Features/`, each with a SwiftUI `View` and an `@Observable` `ViewModel`. Navigation is a `Route` enum driving a `NavigationStack`. Services are injected through `@Environment`. Tests target the ViewModels. An explicit module tree, protocol boundaries between every module, and a navigation tree that is explicit in the router hierarchy. The sentence does not create this template — see [CombineRIBs projects](#why-production-app-is-no-longer-offered). This section describes the projects created from it, and the same RIBs structure is what **2D game** builds on and what the iPhone target of the `watchos-ios-ribs` template gets. ```text theme={null} src-ios/ ├── App/ │ ├── MyApp/ # AppDelegate, SceneDelegate, SceneComponent (composition root) │ ├── xcconfig/ │ └── xcodegen.yml ├── Libraries/ │ ├── MyAppMain/ │ │ ├── RIBs/Root/ # Builder · Router · Interactor │ │ ├── RIBs/Splash/ │ │ ├── RIBs/Main/ │ │ ├── Workers/ │ │ └── Localizable.xcstrings │ └── Theming/ ├── SharedLibraries/ │ ├── AppServices/ # app-lifecycle & shared service protocols │ ├── CombineExtensions/ │ ├── Diagnostics/ │ ├── RIBsExtensions/ │ ├── SharedUtility/ │ └── SimpleTheming/ └── AllTests.xcworkspace # runs every module's tests together ``` **How you'll write features:** one RIB per screen or flow — `Builder` (constructs it), `Router` (attaches and detaches children), `Interactor` (business logic), `View` (SwiftUI or UIKit). Children attach to parents, so the router hierarchy is where the app's navigation state lives, and you can read the whole app off it. Mocks are generated automatically by [Sourcery](https://github.com/krzysztofzablocki/Sourcery) from `/// sourcery: CreateMock` annotations, so every module is unit-testable in isolation. A Splash RIB and a Main RIB come pre-wired under Root, so the app runs the moment it's created. The CombineRIBs structure with the view layer swapped for a SpriteKit scene tree. Builders, Interactors, Routers, Components and Sourcery mocks are identical; what changes is that a RIB's presenter is an `SKScene` or an `SKNode` instead of a SwiftUI view, and the whole app has exactly one view controller — the scene navigator that owns the `SKView`. ```text theme={null} src-ios/ ├── App/ │ ├── MyApp/ # AppDelegate, SceneDelegate, SceneComponent │ ├── xcconfig/ │ └── xcodegen.yml ├── Art/Game/ # source PNGs — the artwork you edit ├── Scripts/pack-atlases.sh # packs Art/ into the texture atlas ├── Libraries/ │ ├── MyAppMain/ │ │ ├── RIBs/Root/ # the app's one view controller (SceneNavigator) │ │ ├── RIBs/Splash/ # SplashScene — waits on the resource loader │ │ ├── RIBs/Menu/ # MenuScene — hosts the node RIBs below │ │ ├── RIBs/Game/ # GameNode — the sample game │ │ ├── RIBs/Settings/ # SettingsNode — sound volume, persisted │ │ ├── Layout/ # SceneLayout, SceneNodes, ZPlane │ │ ├── Content/ # sprite, atlas and sound catalogs │ │ ├── Workers/ # ResourcesLoadingWorker │ │ └── Resources/ # packed atlas + sound effects │ └── Theming/ └── AllTests.xcworkspace ``` **How you'll write features:** two scenes (Splash and Menu) and everything deeper as **node RIBs** — an `SKNode` subclass you `addChild` into the host scene. A node reads no ambient state (`self.scene`, `UIScreen`, the view) and lays out against the metrics its host hands it, so the same node can be attached into a different scene without edits. Animation is `SKAction` choreography rather than a per-frame game loop, and `layoutScene()` runs on presentation, on rotation and on every safe-area change. The SpriteKit machinery — `BaseScene`, the scene navigator, the routing protocols, the sound pool, the atlas loader, the button control — is the open-source [modaal-foundation-spritekit](https://github.com/modaal-agent/modaal-foundation-spritekit) package, pinned by URL in the app module's `Package.swift`. It is a versioned dependency: you import its products, and it is upgraded by moving the pin. The scaffolded app runs immediately: splash → menu → a tap-the-target sample game with a score and a sound effect, plus a settings screen that persists sound volume. The sample game and the four placeholder sprites in `Art/Game/` are there to be replaced — only the sample game in `RIBs/Game` uses them. See [Building 2D games with SpriteKit](/articles/spritekit-games) for what this looks like in practice. Turning the **+ Apple Watch** chip on adds a watchOS app target to whichever Duet combination you picked. It is a target on the project, not a project of its own: the phone app is still what the platform word says, and the watch app is its companion. ```text theme={null} src-ios/App/ ├── / # the iPhone app target └── WatchApp/ # the watch app target ├── WatchApp.swift # @main App entry └── WatchRootView.swift # the placeholder surface to replace ``` What you get on day one: a target in `src-ios/App/xcodegen.yml` that builds and runs on a watchOS simulator, a bundle identifier of `.watchkitapp` with `WKCompanionAppBundleIdentifier` pointing at the phone app, a `watchOS 10.0` deployment target, a row in `.modaal/project.json`, and an entry in the Run picker. `WatchRootView` is a placeholder: the first watch feature replaces it. The Duet feature packages target iOS, so the watch app links none of them and its screens are written for the wrist rather than shared. Ask the agent for them the way you ask for any other screen. Forgot the chip? Ask the agent to add the watch target later — it takes the same path the chip takes. Two templates outside the sentence. **Watch only** (`watchos`) is a standalone Apple Watch app with no iPhone app in the project; **Watch + iPhone** (`watchos-ios-ribs`) pairs a CombineRIBs iPhone app with an MV watch companion. Projects on either keep building and keep getting features. **MV (Model-View)** is a simplified MVVM: there is no ViewModel layer. Views observe `@Observable` services directly through `@Environment`. At watch-screen scope a ViewModel per screen adds a layer without adding testable logic, so MV omits it. Tests target the services. For a new project, the watch is the **+ Apple Watch** chip on a **Production app**, which puts the watch target in a Duet tree. A project folder and a git repository. There is no Xcode project and no scheme, so there is nothing to build or run until you add a target. The sentence does not create this: every combination scaffolds an app. A workspace with no app targets is what an imported repository is, and the agent is told when one is empty — ask it to add a target when you're ready. What was chosen is recorded in `.modaal/project.json` — the template at the project level, and the architecture per target. Both the agent and Modaal's tooling read it, which is why you never have to restate your architecture in a prompt. *** ## Choosing a template Four questions, in this order. The first is the one that decides the most: platform reach comes before architecture. ### 1. Will this ship on Android, now or later? **Yes, or probably** → **Production app**, and the platform word decides which [Duet](/duet) flavor you get. On both, each feature's logic is written once, both apps render it natively, and recorded fixtures gate the two against each other in CI. **Android + iOS** is part of Pro ([plans on modaal.dev](https://modaal.dev/pricing)). | | **Production app for Android + iOS** | **Production app for iOS** | | - | - | - | | Targets on day one | iPhone app + Android app | iPhone app | | Feature logic | Kotlin, consumed by both apps | Swift | | Toolchain on your Mac | Xcode, plus the Android SDK and a JDK | Xcode | | Getting the other platform | Already there | A per-feature, fixture-gated migration into the same repository | Pick **Android + iOS** when Android is in scope now, or when you want both stores at launch. Pick **iOS** when Android is a "probably", or when you want the iPhone app in users' hands before taking on a second toolchain — its features are recorded and specced exactly as in the two-platform flavor, which is what makes the later port bounded work. **No, and I'm certain** → question 2. **Platform reach is a creation-time decision.** A template fixes the architecture for the life of the project. A **Quick prototype** (MVVM) project has no migration path to Duet — Android there means a second project, written again. A CombineRIBs project does have one, taken feature by feature, but it is work a **Production app** avoids entirely. When Android is even a maybe, **Production app** costs less. ### 2. Is this a prototype or a product? **A prototype** — idea validation, an investor demo, a personal utility with a fixed scope → **Quick prototype**. Pure SwiftUI, a View and a ViewModel per screen, no module boilerplate beyond that, and a working app in hours. **A product** — something that will live long, grow past a dozen screens, or earn money → **Production app**, even when only iPhone is planned. **Production app for iOS** is the production template for an Apple-only product. **A Quick prototype project is a throwaway by design.** MVVM has no migration path in Modaal: going to production means starting a second project and carrying the behavior across by hand. Before picking it for something that "might eventually go to production", ask whether you are ready to write it again. If the answer is "probably not" or "I hadn't thought about that", pick **Production app**. The extra structure a production template needs is written by the agent, not by you — generating a Duet feature and generating an MVVM screen differ by seconds of agent time. What differs later is the cost of having picked wrong, and you pay that one. ### 3. Is the screen drawn rather than laid out? Sprite artwork placed at coordinates, animated per frame, hit-tested where it is drawn → **2D game**. It is the only template that scaffolds a SpriteKit scene host, so it is the answer for a game. [The section below](#when-to-pick-the-2d-game-card) covers where that line falls. ### 4. Does the product reach the wrist? Turn the **+ Apple Watch** chip on, and keep the platform word set to where the phone app ships. The watch app is a companion target in the same project, on the same Duet architecture, and the chip is the only way to get one at creation — the kind stays **Production app** even when the wrist is the part you care about. ### Which template for which situation? | Your situation | Sentence | | - | - | | Android planned, now or later | **Production app** for **Android + iOS** | | Both stores at launch | **Production app** for **Android + iOS** | | iPhone first, Android a likely follow-up | **Production app** for **iOS** | | Production app with growth potential, Apple-only | **Production app** for **iOS** | | Fintech, healthtech, regulated industry | **Production app** | | Complex navigation, auth gates, multi-step flows | **Production app** | | Utility with a fixed, bounded scope | **Quick prototype** | | Prototype for idea validation | **Quick prototype**, knowing there is no migration path | | Game, arcade, puzzle, animated lesson | **2D game** | | Workouts, timers, quick capture on the wrist | **Production app** with **+ Apple Watch** on | | You are bringing your own project structure | Import the repository instead of starting from the sentence | ### CombineRIBs projects The **Production app** kind in the sentence creates a Duet project. The template that used to carry that name, `ios-ribs`, scaffolds **RIBs** (Router-Interactor-Builder, via CombineRIBs): explicit module boundaries, a navigation tree that lives in the router hierarchy, and protocol contracts between modules. The sentence does not create it, because the two Duet combinations cover every app it scaffolds — with a recorded behavior corpus and a route to Android on top. Three things follow: * **Projects already on it are fully supported.** They keep building, features written today follow the same RIBs patterns, and the agent knows them. * **A game is the exception.** No Duet template scaffolds a SpriteKit scene host, so **2D game**, which is RIBs with a SpriteKit view layer, is reachable from the sentence and is the answer for a game. * **There is a way across.** A CombineRIBs project can be carried to the Duet shape feature by feature, in place — [Migrating a CombineRIBs app to Duet](/articles/combineribs-to-duet) walks through the stages, what each one costs, and where you can stop. Starting a new project on **Production app** means that migration is never needed. *** ## When to pick 2D game **Pick it when the screen is drawn rather than laid out** — sprite artwork placed at coordinates, animated per frame, hit-tested where it is drawn. Games, arcade and puzzle apps, animated lessons, and kids' apps where the play is the product. **Stay on a conventional kind for a conventional app, including an animation-heavy one.** Rich SwiftUI animation is not a reason to move to SpriteKit. Building a single playful screen on a scene tree means that screen takes on the scene lifecycle, the layout pass and the atlas pipeline, and its UI is assembled from `SKNode` subclasses rather than from your existing SwiftUI views. **RIBs → RIBs + SpriteKit is additive.** The RIB tree, the modules and the tests keep their shape; what gets added is the SpriteKit packages and a scene host. So an existing CombineRIBs project that needs a drawn screen can ask the agent to add the SpriteKit surface at that point. A Duet project has no equivalent move — no Duet template scaffolds a scene host. If the product is a game, pick **2D game** at creation and take CombineRIBs with it. [Building 2D games with SpriteKit](/articles/spritekit-games) walks through what the template gives you and what the first turn looks like. *** ## Picking a template for a real app **Context.** Max wants a simple interval training app. Three screens: workout templates, interval settings, and the timer itself with start / pause / stop. A personal project he might share with friends. No cloud — data stored locally in SwiftData. **Prototype or product?** A personal utility. Even on the App Store it's unlikely to reach 50 screens. No Android plans, three screens, linear navigation (list → settings → timer). Nothing points at a production template. **Decision: Quick prototype (MVVM).** A production template would add per-feature module structure — and, on a Duet template, a spec and a recorded fixture corpus per feature — that three linear screens never use. MVVM covers the whole app: a `TimerViewModel` with `@Observable` holding timer state, a `NavigationStack` with `NavigationLink` and `.sheet()`, `@Environment` for the persistence service, and tests against the ViewModel with zero navigation mocks. A few hours after describing the idea, Max has a working MVP. If he later adds a WidgetKit widget or a watch companion, the main app's architecture doesn't constrain them — those targets run on pure SwiftUI either way. **Takeaway.** For 3–10 screens with a clear, bounded scope, MVVM is the right call. Don't "play it safe" with a heavier architecture just in case. **Context.** A fintech building a mobile bank: registration with KYC verification, balances, P2P and IBAN transfers, filtered transaction history, profile settings, push notifications, biometric auth. Android planned in six months. **Prototype or product?** Production. A regulated industry, real money, and a codebase that will be maintained for years. * **Android in six months.** **Production app for Android + iOS** is the only combination that emits an Android app target. Picking it at creation makes Android a target rather than a migration, and the six-month date stops being a second project plan. * **Navigation.** KYC is a multi-step flow with branches (document photo → selfie → verification → waiting → result, with back-navigation). Transfers are another flow behind an auth gate. In Duet those transitions are reducer state, recorded as fixtures — so the flow is pinned byte-for-byte and both apps replay the same recording. * **Regulatory scrutiny.** Every flow ends up with a written spec under `parity/feature-specs/` and an executable recording of what it does. That corpus is produced by ordinary feature work, not by a separate documentation effort. * **Security.** Features are separate modules with typed boundaries. The payments module has no access to KYC's internal state, and the compiler enforces that rather than convention. * **Native SDK work.** Biometrics, document-verification SDKs and custom PIN entry live in workers behind ports, with one implementation per platform. The reducer names the effect; the platform supplies the I/O. **Decision: Production app for Android + iOS (Duet, Kotlin Multiplatform).** This isn't about team size — it is the right pick for a solo developer with an agent, because the agent generates each feature against a clear contract and the fixture gate catches a behavior change on the commit that caused it. **Takeaway.** When a product must exist on both stores and is judged on whether it behaves correctly, write the behavior once and let CI prove the two apps agree. **Context.** A solo founder building a marketplace for plumbers, electricians and cleaners: an offerings feed, provider profiles, booking with a date/time picker, in-app chat, Stripe payments, order history, user profile. Android is coming, but "not now." The business model anticipates growth. **Prototype or product?** There's a business model with payments and growth plans — production. * **"Android not now"** commonly becomes an urgent requirement 6–8 months after launch. On a **Production app** that is a scheduled port; on **Quick prototype** it is a second project. * **Navigation** is a real flow: pick service → pick date → confirm → pay → result, with an auth gate (browse without an account, book with one). Chat is a separate real-time flow. * **Integrations**: Stripe, WebSockets, push, geolocation. **Decision: Production app for iOS (Duet, Swift flavor).** One toolchain and one set of screens to launch with, and every feature recorded and specced as it is written. When Android is scheduled, the Kotlin core and Android shell are added to the same repository and features cross one at a time, each verified against fixtures recorded months earlier. **The line between the two platform words:** if Android has to ship within roughly six months, pick **Android + iOS** at creation instead — the Android app is then built alongside the iPhone one rather than ported after it. **Takeaway.** When a project shows signs of growth — a business model, non-trivial navigation, possible Android — pick **Production app** from the start. Which platform word depends on when Android has to exist, not on whether it will. **Context.** An internal app for \~500 employees: contact directory, office map, meeting-room booking, news feed. Tight budget, results needed fast. Data from a corporate REST API. No Android — everyone has an iPhone. **Prototype or product?** Less clear-cut than the others. It's used in production, but it doesn't *grow* — the scope is fixed. It will never become a marketplace or a social network. Navigation is linear: tab bar → list → detail, with booking as a simple form sheet. **Decision: Quick prototype (MVVM).** Fixed scope, linear navigation, no Android. A production template would add module structure the fixed scope never uses. The distinction that matters: the key question in the decision tree isn't "will this be in production?" — it's **"will it grow?"** A corporate directory with a clear spec doesn't. If someone asks for vacation requests and expense tracking a year later, *that's* when you decide whether the next version starts as a new **Production app** project. Deciding now, just in case, is premature optimization. **Takeaway.** Not every "real" app needs a production architecture. Bounded scope means MVVM delivers a better result in less time — corporate projects included. **Context.** Anna built a habit tracker — three screens: habit list, add habit, stats. She picked **Quick prototype**, correctly, and the agent generated a working version in hours. She shipped it and started gaining users. Four months later: 50,000 users, an investor interested, and new requirements — social challenges, a subscription paywall, CloudKit sync, widgets, a watch companion, five languages. **What she's facing.** This is the classic "throwaway prototype that accidentally became the product." Two options: * **A — Evolve the MVVM codebase.** Keep adding features, and for complex flows (paywall, challenge onboarding) add lightweight flow coordinators: `@Observable` state machines that own navigation for a single flow. Not full MVVM+C — no coordinator protocol, no coordinator tree, no parent-child lifecycle. Just one concrete object per complex flow. * **B — Restart on a Production app.** MVVM has no migration path in Modaal, so this is a new project rather than a conversion: the agent rebuilds the app on a Duet template, and the business logic — models, services, CloudKit code — carries across as-is, because it lives in SPM modules rather than the architectural layer. The screens are written again. **Recommendation.** If a few days can be set aside: **B**, now, while the app is three screens plus the new requirements rather than thirty. With an agent that rebuild is days of work, and it is the point at which Android stops being a separate project. If there's genuinely no time and the product is growing daily: **A** as a stopgap. Flow coordinators for the paywall and onboarding hold for a few months. Record the restart as the next planned piece of work — the longer it waits, the more screens it has to cover. Worth noting: **widgets and watch targets are pure SwiftUI either way**, so they carry across untouched. **Takeaway.** Two rules apply. At the start, be honest that **Quick prototype** means throwaway — if there's even a 30% chance of growth, start on **Production app**. When it takes off, restart while the codebase is still small. *** ## Changing your mind later Not everything the sentence sets is permanent. Here's what is: | Decision | Changing it later | | - | - | | **Name**, **display name**, **version**, **devices**, **orientations**, **minimum deployment target**, **app icon** | Edit them in the **Project Settings** tab of the sidebar. | | **Bundle identifier** | The **Project Settings** tab, until you ship; fixed once a build reaches TestFlight or the App Store. | | **Adding targets** — watch companion, widgets, extensions | Ask in chat; the agent scaffolds and wires them. A watch target added in chat takes the path the **+ Apple Watch** chip takes. | | **The Android app**, on a Duet project | Already there on **Production app for Android + iOS**. On **Production app for iOS**: a per-feature migration into the same repository — see [Adding Android to an iPhone-only project](/articles/duet#adding-android-to-an-iphone-only-project). | | **Architecture** | The one real commitment. MVVM has no migration path — going to production means a second project. [CombineRIBs → Duet](/articles/combineribs-to-duet) is per-feature and in place. RIBs → RIBs + SpriteKit is additive. Duet projects stay Duet projects. | Everything else — features, screens, navigation, integrations, pivots — is ordinary work you do in chat. See [Modes and prompts](/articles/modes-and-prompts) for how to drive it. The first turn writes a `PRD.md` from your description. If you would rather bring your own, [the app requirements document template](https://modaal.dev/blog/app-requirements-document-template) sets out what to put in each section. **Need help deciding?** Ask in our [community](https://discord.gg/ng84Jm92tm) — describe what you're building and someone will sanity-check the combination with you. *** *Modaal currently supports Duet in two flavors for multiplatform projects, MVVM, RIBs and RIBs + SpriteKit for iOS, and MV for watchOS. Duet is pre-release; Android support is available to every account.* # Should You Share Code Between iOS and Android? Source: https://docs.modaal.dev/articles/share-code-ios-android Three ways to ship iOS and Android: write two apps, share everything, or share the logic and keep the UI native — with costs, returns, and who should skip it. Share the business logic when you ship both platforms and your product's value is in its behavior — rules, flows, money, data. Keep the interface native on each platform, because the interface is where the platforms genuinely differ. The rest of this page is the case for that split: the three ways to ship two apps, what each way can prove about behavior, what sharing costs in practice, and the products for which sharing is the wrong call. ## What are the three ways to ship two apps? Every iOS-and-Android product takes one of three routes: 1. **Write both apps in full.** A Swift codebase and a Kotlin codebase, each implementing every rule. Two teams (or one team twice), full platform fidelity, and every behavior change made twice. Duet is adding a variant of this route that checks the two implementations against one set of recordings: [Duet Twins](/articles/duet-twins). 2. **Share everything, including the interface.** One implementation in one language — [Flutter](/articles/duet-vs-flutter), [React Native](/articles/duet-vs-react-native), [Compose Multiplatform](/articles/duet-vs-compose-multiplatform), or [Ionic with Capacitor](/articles/duet-vs-ionic-capacitor) — with the framework supplying the widgets on both platforms. 3. **Share the logic, keep the interface native.** One implementation of the rules and flows; two thin interface layers, each in its platform's own toolkit. Google [supports Kotlin Multiplatform for exactly this split](https://developer.android.com/kotlin/multiplatform), and it is the route Duet takes — **Duet** is Modaal's cross-platform parity framework for native iOS and Android apps. The third route is established practice, not an experiment: Forbes, McDonald's, Cash App and Google Docs ship shared Kotlin logic under native interfaces, per [JetBrains' case studies](https://kotlinlang.org/case-studies/) and [Google's Kotlin Multiplatform page](https://developer.android.com/kotlin/multiplatform), and the same shape exists with a Rust core (Mozilla's UniFFI bindings, Signal's libsignal). ## How do you know both apps behave the same? The three routes give three different answers, and the answer is the real decision: * **Identity by convention** — two full codebases. The same rule is implemented twice, and agreement rests on code review and manual QA finding every divergence. Nothing mechanical fails when the two apps drift apart. * **Identity by construction** — one shared implementation. There is nothing to compare, so agreement is automatic — up to the boundary. Where the shared code calls into per-platform code (a platform channel, a native module), the guarantee stops, and that boundary code is back to identity by convention. * **Identity by verification** — two implementations (or one shared core consumed twice) checked against the same executable contract. In Duet, each feature's behavior is recorded as fixture files; both apps replay them and [CI compares the results byte-for-byte](/articles/duet#what-you-get), so a divergence is a failing build rather than a bug report. Two-by-two grid placing cross-platform approaches by who draws the screen (platform toolkit vs framework renderer) and where logic runs (shipped runtime vs compiled). React Native, .NET MAUI and NativeScript top-left; Flutter and Ionic top-right; Compose Multiplatform bottom-right; Duet, two native apps, KMP shared-core practice, Rust cores and Skip bottom-left. [The best cross-platform frameworks in 2026](/articles/best-cross-platform-framework) places every mainstream option on this grid, one comparison column per approach. ## What does "business logic" mean concretely? Business logic is the set of decisions that must not differ between platforms: what a tap is allowed to do, what happens when a payment fails mid-flow, in which order results land, what gets persisted and when. In a Duet feature it is literally one [pure reducer](/articles/duet-glossary#reducer) — current state and an action in, new state and requested effects out — with clocks, network and storage behind effects that platform [workers](/articles/duet-glossary#worker) perform. Pixels are not business logic. Screen layout, navigation chrome, animation, haptics and typography are where iPhone and Android users hold different expectations — Apple's Human Interface Guidelines on one side, Material 3 on the other — and where sharing produces an app that feels foreign on at least one platform. The split holds even when a screen is data-driven: *what is on screen* is a value in shared state; *how it is rendered* stays per platform. [Handling platform-specific UI](/articles/cross-platform-ui-parity) covers where that line sits case by case. ## What does sharing the logic cost? Two costs, stated plainly: * **The views are written twice.** Each feature gets a SwiftUI screen and a Jetpack Compose screen. The logic, navigation decisions, effect handling and the test corpus are written once, so the second view layer is rendering work against behavior that already passes. * **A change touching both platforms costs more than one platform.** In Modaal's own runs, a behavior change shipped to both apps measured roughly 40% more agent effort than the single-platform equivalent — not the 100% of doing everything twice, and the delta is concentrated in the second view layer. What sharing does not cost: platform access. The shared core compiles into each app as an ordinary library; anything the platform can do, the app can still do, because the interface layer and the workers are native code. That includes the screens that must be native and fast — a Metal-drawn view, a camera pipeline, a video editor. In the share-the-logic route they are ordinary platform code, with the shared core deciding when they start and what they return; in the share-everything route each one is an escape through the framework's native bridge, written per platform anyway in Swift and Kotlin that someone writes and the app build compiles. [The survey page](/articles/best-cross-platform-framework#which-approaches-escape-to-native-for-a-heavy-screen) scores every approach on that escape. ## What do you get for it? * **One bug, fixed once.** A logic defect lives in one implementation. The fix ships to both stores from one edit. * **Behavior parity you can point at.** The recorded fixtures are an artifact — replayed on both platforms on every push, byte-compared in CI. "Do the apps agree?" has a mechanical answer, not an opinion. * **Native look and feel on both platforms.** Each app uses its platform's toolkit, inherits its accessibility behavior, and adopts new OS releases on the platform's schedule — there is no cross-platform widget layer to wait on. * **An exit that costs nothing.** Both apps are ordinary native codebases in one repository; the shared core is a library either app could in principle replace. Nothing is hostage to a framework's runtime. ## Who should not share code? * **Single-platform products.** If Android (or iOS) is genuinely never coming, the shared core's discipline still buys fast, deterministic tests, but the parity machinery is overhead you chose without the payoff. * **Games and heavily drawn interfaces.** A physics loop or a custom-rendered playfield is not reducer-shaped work, and the drawn surface *is* the product — a shared canvas framework or a game engine fits better. Modaal's own guidance says the same: its 2D game template is not a Duet template. * **Teams committed to two independent native teams.** Sharing logic couples the teams' release trains at the core. If your organization deliberately staffs two autonomous teams and accepts double implementation as the price of independence, that is a coherent position — it is identity by convention, and manual QA is its verification budget. ## Common questions A single shared implementation is the cheapest way to get two app icons on two stores, and for interface-light products it is a good trade — the survey page's best-fit row says so. The costs arrive later and are specific: the interface is the framework's on both platforms, and shared code that crosses into per-platform modules leaves the guarantee. Compare the routes on those terms, not on launch cost alone. In shared-logic practice, the logic majority: Forbes states 80%+ shared logic for its KMP apps. In a Duet project, each feature's state, actions, reducer, effects and test corpus are shared; the two view layers and the platform workers are not. The number depends on how interface-heavy the product is. The shared core is Kotlin compiled into two ordinary native apps — a library, not a runtime. Both codebases open in Xcode and Android Studio and ship to the stores like any native app. That is different in kind from a shared-UI framework, whose widgets and runtime the app cannot ship without. Yes, and it is cheaper if the iOS app records its behavior from day one. Duet's iPhone-first flavor does exactly that: features are recorded and specced as they are built, so the later Kotlin core is written against an executable specification instead of reverse-engineered. The Duet overview covers the per-feature migration. Sharing logic with a web app is the same decision one platform wider, and Kotlin and Rust cores both compile for it. Duet targets iOS and Android; if web-plus-mobile from one codebase is the requirement today, that is a real advantage of Flutter and Compose Multiplatform — see the survey page's platform-coverage section. Duet projects are scaffolded by [Modaal](https://modaal.dev) — [starting a Duet project](/articles/duet#starting-a-duet-project) walks through the two template cards. ## Sources and further reading * [Kotlin Multiplatform on developer.android.com](https://developer.android.com/kotlin/multiplatform) — Google's support statement for sharing business logic between Android and iOS * [JetBrains Kotlin Multiplatform case studies](https://kotlinlang.org/case-studies/) — Forbes, McDonald's, Cash App and others on the shared-logic shape * [Crux](https://github.com/redbadger/crux) and [UniFFI](https://github.com/mozilla/uniffi-rs) — the same shared-core shape with Rust * [Apple Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/) and [Material 3](https://m3.material.io) — the two design systems a native interface answers to ## Read next Every mainstream option on one axis — who draws, where logic lives, what proves agreement — with a best-fit row. The product behind the third route: one shared core, two native apps, and a CI gate that fails when they disagree. Where the shared/native line sits on the rendering side, and how deliberate divergence is recorded. The same architecture argued from the testing side: pure reducers, effects as data, recorded scenarios. The third route in code: nine tutorials build one app in Kotlin, SwiftUI and Compose, with recordings gating both apps. Which platform to ship first when you are not shipping both, on modaal.dev. # Building 2D Games with SpriteKit Source: https://docs.modaal.dev/articles/spritekit-games The 2D game template scaffolds a running SpriteKit app — scenes, sprites, sound and a sample game — on a RIBs module structure with a SpriteKit view layer. SwiftUI lays out a tree of views. A game screen is a tree of sprites: artwork placed at coordinates, animated per frame, hit-tested where it is drawn, and ordered by explicit z-position. [SpriteKit](https://developer.apple.com/documentation/spritekit) — Apple's 2D scene-graph framework — models that tree directly. The **2D game** template starts you on SpriteKit, wired into the RIBs module structure that [CombineRIBs](/articles/new-project#why-production-app-is-no-longer-offered) projects use. A 2D game running on iPhone: a games grid, a countdown, then an arithmetic mini-game with sprite characters, hearts, a score and a timer ## What you get on the first build Pick the card, press **Create**, and the app runs: splash → menu → a tap-the-target sample game with a score and a sound effect, plus a settings screen that persists sound volume. Five pieces you would otherwise write yourself are already wired behind that sample: * **A resource gate.** `ResourcesLoadingWorker` registers the fonts, makes the sprite atlas resident and warms the sound pool; the splash waits on it. If a resource fails to load, the worker records it to `Diagnostics` and publishes it on `loadingIssues` for the splash to display, then finishes loading and opens the gate anyway. * **An artwork pipeline.** Source PNGs live in `Art//`, and `Scripts/pack-atlases.sh` packs them into the committed atlas page the app reads. Run it when the artwork changes; `Scripts/pack-atlases.sh --verify` checks the committed page against the sources without re-packing. * **Pooled audio.** A fixed-size pool of preloaded `AVAudioPlayer`s, rotated least-recently-used. Overlapping plays each get their own player up to the pool size; past that the new sound is dropped rather than cutting off a playing one. * **Idempotent layout.** `layoutScene()` runs on presentation, on rotation and on every safe-area change. Chrome is positioned against `safeAreaFrame` and the playfield against the full frame, so the notch and home indicator are accounted for. * **Tests that pass from the first build**, registered in the same all-modules workspace as the other templates. The sample game's tests run its rules without instantiating SpriteKit, because the score and sound logic is Interactor state. The SpriteKit machinery — the scene base class, the scene navigator, the routing protocols, the sound pool, the atlas loader, the button control — is the open-source [modaal-foundation-spritekit](https://github.com/modaal-agent/modaal-foundation-spritekit) package, pinned by URL in your `Package.swift`. It is a versioned dependency: you import its products, and it is upgraded by moving the pin rather than by editing files in your project. ## What changes from a RIBs app Compared with a RIBs app built from SwiftUI views, such as a project created from the CombineRIBs template, a RIB's presenter is an `SKScene` or an `SKNode` subclass instead of a SwiftUI view. The rest is the same: Builder, Interactor, Router and Component, the same protocol boundaries, the same generated mocks. One rule follows from that: **build screens as node RIBs, not scene RIBs.** Presenting a scene runs a transition and discards the outgoing scene's node tree. Attaching a node RIB is an `addChild` into the scene already on screen, and the node remains attachable elsewhere afterwards — it reads no ambient state (`self.scene`, `UIScreen`, the view) and lays out against the metrics its host hands it, so the same node can be attached into a different scene, or into a host app's scene, without edits. The scaffolded app is built that way: two scenes, Splash and Menu, with the game and settings screens as nodes attached into the Menu scene. Ask the agent for a new screen and it scaffolds the RIB for you — the right variant, plus the artwork wiring when the screen draws sprites. ## Start a 2D game project Set the sentence on the [Modaal](https://modaal.dev) home screen to **I'm building a 2D game**, describe the game in the composer, and press **Start new project**. The first turn writes a `PRD.md` and a first feature spec, as it does for every template. The architecture is already recorded in `.modaal/project.json`, so that turn goes straight to product scope. Two prompts with [enough detail](/articles/modes-and-prompts#detail-level-drives-the-result) for the agent to plan from: ```text theme={null} A puzzle game where you tap falling blocks to match colors. Three lives, a combo multiplier, and a game-over screen with a high score. ``` ```text theme={null} An arithmetic practice game for kids: a character hops between numbered blocks to answer the sum at the top. Hearts for lives, a timer, and a star score that persists between sessions. ``` The sample game and the four placeholder sprites in `Art/Game/` are there to be replaced. Only the sample game in `RIBs/Game` uses them, and deleting them and re-running `Scripts/pack-atlases.sh` also drops the \~1 MiB atlas page they occupy. ## When not to use it Pick a conventional kind for a conventional app, including an animation-heavy one — **Production app** for a product, built on [Duet](/duet), and **Quick prototype** for a throwaway idea. Rich SwiftUI animation is not a reason to move to SpriteKit. Building a single playful screen on a scene tree means that screen takes on the scene lifecycle, the layout pass and the atlas pipeline, and its UI is assembled from `SKNode` subclasses rather than from your existing SwiftUI views. If the graphics requirement is uncertain, weigh the two directions before you pick. A Duet project has no scaffolded route to one: no Duet template scaffolds a SpriteKit scene host. An existing CombineRIBs project can gain one: **RIBs → RIBs + SpriteKit is additive, not a rewrite** — the RIB tree, the modules and the tests keep their shape, and what gets added is the SpriteKit packages and a scene host. Ask the agent to add the game surface at the point you need it. CombineRIBs is not one of the sentence's combinations, so if a drawn screen is likely, start on **2D game**. *** More on picking a template: [Starting a new project](/articles/new-project). For a second opinion on your idea, ask in the [community](https://discord.gg/ng84Jm92tm). # Add In-App Purchases and Subscriptions to an iOS App Source: https://docs.modaal.dev/articles/storekit-testing Use a local StoreKit configuration file to test in-app purchases and subscriptions before your products exist in App Store Connect — right from Modaal. Testing in-app purchases against Apple's servers requires setup in App Store Connect: products, agreements, banking and tax information. Until all of it is in place, `Product.products(for:)` returns an empty array without an error, and your paywall renders with nothing in it. The standard way to develop against this is [StoreKit Testing](https://developer.apple.com/documentation/xcode/setting-up-storekit-testing-in-xcode), Apple's local testing mechanism: your app loads test products from a `.storekit` configuration file instead of from Apple's servers, so you can build and test the entire purchase flow before anything exists in App Store Connect. [Modaal](https://modaal.dev) supports it directly — select a `.storekit` file in Run Options, run, and your paywall fills in. What to charge and which tiers to offer is a separate decision — [how to monetize your mobile app](https://modaal.dev/blog/how-to-monetize-your-mobile-app) covers it. You don't need to write Swift to follow this guide. Everything below is a setting in Modaal or a [prompt](/articles/modes-and-prompts) you can give to your agent. If your app doesn't have a paywall yet, start with [Starting from scratch](#starting-from-scratch). *** ## The three StoreKit environments Your app can load products from three different sources. Most paywall problems come down to being in a different environment than you think, so it's worth understanding how the three differ. | | StoreKit Test | Sandbox | Production | | - | - | - | - | | Where products come from | A local `.storekit` file | Apple's servers | Apple's servers | | Requires App Store Connect products | No | Yes | Yes | | Requires the Paid Applications Agreement | No | Yes | Yes | | Requires App Review approval | No | No | Yes | | Can simulate failures and time travel | Yes | Limited | No | | Used by TestFlight builds | No | Yes | No | The `.storekit` file is a launch setting, not part of your app. Modaal applies it when it starts the app, which is why the change takes effect immediately in the simulator — and why the file can never end up in a TestFlight or App Store build. It isn't compiled into the binary. Start in the StoreKit Test environment. It has no dependency on App Store Connect, so you can build and iterate on a paywall before you've created a product, signed an agreement, or filled in a tax form. *** ## Select a StoreKit configuration The cogwheel to the right of the **Run** button opens **Run Options**, Modaal's equivalent of the scheme editor in Xcode. Modaal toolbar with the Run Options cogwheel highlighted Click the cogwheel. Modaal lists every `.storekit` file it finds in your project, so there's nothing to type or browse for. Run Options dialog with a StoreKit Configuration dropdown Modaal saves your choice in the project's scheme, so it persists between sessions, survives rebuilds, and appears in Xcode as well. The setting takes effect the next time you run the app; it doesn't affect an app that's already running. Run the app and the paywall fills in. The build log records the result of every run: Build log line reading: StoreKit configuration: Onesec/Onesec.storekit → simulator Build log line reading: StoreKit configuration: none — cleared Edits to the `.storekit` file also take effect on the next run. Change a price, run the app, and the new price appears — you don't need to delete the app or reset the simulator. *** ## Confirm which environment you're in Product identifiers can't tell you which environment you're in. The identifiers in your `.storekit` file are deliberately the same as the ones in App Store Connect — that's what lets the same code run in every environment — so a populated paywall looks equally plausible in either one. Prices and product names are the reliable signal. Here is the same app, in the same simulator, with the same product identifiers, run twice: Paywall price row showing $0.99 per month and $7.99 per year Paywall price row showing $0.99 per month and $6.99 per year The annual plan costs $7.99 in one run and $6.99 in the other, and the discount badge recalculates to match. The app is behaving correctly in both cases; it's loading its products from a different source. A paywall that looks correct isn't proof that your configuration is applied. When the configuration is cleared, the app requests products from Apple without reporting an error, and if your App Store Connect setup is complete you get real products at real prices. When in doubt, compare a price on screen with the price in your `.storekit` file. *** ## The setting is shared with Xcode Modaal writes your selection into the project's scheme — the same place the scheme editor in Xcode stores it. Open the project in [Xcode](/guides/setup-xcode) and the configuration is already selected; run it with ⌘R and you get the same result. | | Run from Modaal | ⌘R from Xcode | | - | - | - | | **Local configuration** | Paywall run from Modaal with local products | Paywall run from Xcode with local products | | **None** | Paywall run from Modaal with App Store products | Paywall run from Xcode with App Store products | There's nothing to keep in sync, and no reason to avoid opening the project in Xcode when you want its additional StoreKit tooling — the **Debug → StoreKit → Manage Transactions** window lets you refund, expire, and fast-forward subscriptions by hand. *** ## Testing on a real device Modaal applies the configuration on the simulator. On a physical device it writes the setting into your scheme but can't inject it at launch; that part of the mechanism belongs to Xcode. To use local test products on a device, open the project in Xcode and run it from there. Both the Run Options dialog and the build log note this, so you always know which case you're in. This is a limitation of how Modaal launches apps on devices, not a limitation of StoreKit. Local test products work on a physical iPhone in both Debug and Release builds — the app just has to be launched by Xcode. *** ## Starting from scratch If your app has no paywall yet, the fastest route is to build it against local products from the start: > Add a Premium subscription with monthly and annual plans and a one-week free trial. Create a StoreKit configuration file so we can test it locally, then build a paywall screen that loads the plans and shows the annual savings. Confirm that the build log says the configuration was applied, and that the prices on screen match your file. Change prices, trial lengths, and copy in the `.storekit` file and run again. None of this touches App Store Connect, so there's nothing to wait for and nothing to undo. When the flow is right, create the products in App Store Connect with the same product identifiers, complete your agreements, and do a sandbox pass on a device. *** ## Fix an empty paywall and other StoreKit failures Start with the build log: was a configuration applied or not? The two cases have entirely different causes. If a configuration was applied (the log shows `→ simulator`) and the paywall is still empty, check the following, in order of likelihood: 1. **Product identifier mismatch.** The identifiers in the `.storekit` file must match the identifiers your code requests exactly. This is the most common cause. 2. **A subscription outside its group.** In a `.storekit` file, auto-renewable subscriptions belong in a subscription group, not in the general products list. A subscription in the wrong list is never returned. 3. **You're on a physical device.** See [Testing on a real device](#testing-on-a-real-device). A prompt for your agent: > The paywall is empty. We're running with the StoreKit configuration applied. Compare the product IDs in the .storekit file against the IDs the app requests, and check that subscriptions are in a subscription group. Fix any mismatch. If no configuration was applied (the log shows `cleared`, or there's no StoreKit line at all), the app is requesting products from App Store Connect — continue to the checklist below. Each of the following produces the same symptom — an empty product list with no error. Check them in order of how often each is the cause: 1. **The Paid Applications Agreement isn't active.** This is the most common cause. In App Store Connect, under **Business**, the agreement must be signed, the tax forms completed, and a bank account attached. Until all three are done, StoreKit returns no products, and nothing else on this list matters. 2. **The product is in the Missing Metadata state.** Products in this state are never returned. Add a reference name, product identifier, price, and at least one localization — and for subscriptions, a group and a duration. A product in the Ready to Submit state is returned. 3. **The bundle identifier doesn't match.** Your app's bundle identifier must exactly match an existing App Store Connect record. The build itself doesn't need to be uploaded. 4. **The changes haven't propagated yet.** New products and metadata changes can take from a few minutes up to an hour to appear. If everything above is in order, wait and try again — there's nothing to fix in your code. App Review approval is not required for any of this. Products in the Ready to Submit, Waiting for Review, or In Review states — even rejected products — are all returned outside production. This is almost always the environment rather than a bug. Compare the price on screen with the `displayPrice` in your `.storekit` file: * If they match, the app is using your local test products. * If they differ, the app is using your real App Store Connect products, and the configuration isn't applied. If you expected local products, open Run Options, confirm that a file is selected, and run again. The setting takes effect on the next run. A completed purchase and a granted entitlement are two different things, and the gap between them is where hand-written purchase code usually goes wrong. Check for three causes: 1. **The transaction was never finished.** Finish every successful transaction after you grant access. StoreKit redelivers unfinished transactions indefinitely, which can leave the user stuck. 2. **Access is stored instead of derived.** A flag like `isPremium = true` saved at purchase time survives refunds, expirations, cancellations, and reinstalls. Derive access from the current entitlements at every launch instead. 3. **Nothing listens for transaction updates.** Purchases can complete outside your paywall — an approved Ask to Buy request, a renewal, a purchase made on another device, a refund. Start a transaction listener at app launch so the app hears about them. > After a successful purchase the app still shows the free tier. Check that we finish the transaction after granting access, that premium status is derived from the current entitlements rather than a stored flag, and that we have a transaction updates listener running from launch. To verify the fix, buy the subscription with a local configuration applied, then expire or refund it from Xcode's **Debug → StoreKit → Manage Transactions** window and confirm that the app revokes access. Restoring doesn't replay a purchase record you keep — it re-reads the user's current entitlements from Apple. When Restore appears to do nothing, it's usually one of the following: * The button runs a purchase flow instead of re-reading entitlements. * Access is stored locally, so a fresh install has nothing to read and nothing to restore. * The purchase was made in a different environment. A purchase made against local test products doesn't exist in sandbox, and vice versa — restore in the environment you bought in. To test it properly, make a purchase, delete the app from the simulator, reinstall with Run, and tap Restore. > Delete and reinstall the app, then check that Restore Purchases brings back the subscription without asking me to buy it again. Simulating failure is what the StoreKit Test environment is for, and the main reason not to do all of your testing in sandbox. With a configuration applied, open the project in Xcode and use **Debug → StoreKit → Manage Transactions** to fail transactions, approve or decline Ask to Buy requests, expire subscriptions, trigger billing retry, or issue refunds. The `.storekit` file also has settings for injecting errors and simulating slow networks. Scenarios worth covering before you ship: * Purchase interrupted (app killed mid-transaction) * Purchase declined by the payment method * Subscription expires and isn't renewed * Subscription enters billing retry, then recovers * A refund is issued after the fact * Ask to Buy — a parent approves *or* denies > Write a test that covers what happens when a purchase is interrupted and the app is relaunched before the transaction finishes. The entitlement should still be granted on relaunch. This is expected behavior, and the most important gap to close before shipping. TestFlight builds always use the sandbox environment. Your `.storekit` file was never part of the build, so a TestFlight build is your app's first real contact with App Store Connect — and the first moment the agreement and metadata problems from the checklist above can surface. Sandbox differs from StoreKit Test in a few ways: * Testers use their regular Apple Account; no separate sandbox account is needed. * Purchases are free, and subscription renewals are accelerated. * Auto-renewable subscriptions stop renewing after about six cycles. * None of the StoreKit Test tools are available — no forced failures, no time travel, no clearing transactions. Before you rely on TestFlight, do one sandbox pass on a real device: sign in under **Settings → Developer → Sandbox Apple Account** and run a build with no `.storekit` file attached. That run is what proves your receipts, your server notifications, and your backend against Apple's real servers. Ask your agent to create one. It knows the format and where the file belongs: > Create a StoreKit configuration file for the app with our subscription products, and wire it up so I can select it in Run Options. The agent adds the file and configures the project so the file stays out of your shipped app — local test prices don't belong in a build that goes to the App Store. You can also create one in Xcode with **File → New → File → StoreKit Configuration File**. Either way, the file appears in Run Options the next time you open the dialog. A configuration that covers most apps: * One monthly and one annual subscription in a single group, with the annual plan priced so the savings are easy to see. * An introductory offer on both — a free trial is the most common. Create them in your `.storekit` file first and iterate on the paywall locally. Once the design settles, create matching products in App Store Connect with the same product identifiers — that's what lets the same code work in every environment. Your first in-app purchase must be submitted for review together with a new version of your app. After that first submission is approved, later products can be submitted on their own. Plan the first submission with this in mind — it isn't a separate, quicker path. *** ## Before you ship Every purchase state has been exercised in StoreKit Test — including the failure cases, not just the happy path. Restore Purchases works from a fresh install. One sandbox pass has been done on a real device, with no `.storekit` file attached, against real App Store Connect products. The Paid Applications Agreement is active — signed, tax forms complete, bank account attached. Product identifiers in App Store Connect exactly match the ones in your code. Your first in-app purchase is submitted together with the new app version. Entitlements are checked at launch rather than remembered, so a refund or an expired subscription takes access away. Ask your agent to audit the last one for you: > Check that premium access is derived from the current StoreKit entitlements every launch, and that we're not persisting an "is premium" flag anywhere that would survive a refund or an expired subscription. # How to Build Testable Android Apps: What Duet Fixes in Place Source: https://docs.modaal.dev/articles/testable-android-apps Make Android apps testable by keeping logic in pure reducers: nondeterminism lives behind effects, and recorded scenarios replay as the regression suite. The reliable way to make an Android app testable is to keep every decision in code that takes no environment: a pure function from state and action to new state, with clocks, IDs, network and permissions pushed behind effect values a worker performs later. **Duet**, Modaal's cross-platform parity framework for native iOS and Android apps, fixes that architecture at project creation instead of leaving it as a per-team discipline — and then records each feature's behavior as fixture files that CI replays on every push. This page states what stays standard Android, what the scaffold fixes in place, what that costs, and the measured results from a shipped app. ## What stays standard Android? The toolchain is unchanged: Kotlin, Jetpack Compose, Gradle, and a tree Android Studio opens and runs — nothing proprietary in it, no code generator you cannot leave. The architecture is the one Google's own [app architecture guide](https://developer.android.com/topic/architecture) recommends in general form — unidirectional data flow, state as data, a clear UI/logic boundary. What Duet changes is who enforces it: the shape is scaffolded and gated rather than described in a document, and the logic layer compiles to `commonMain` so the iPhone app in the same repository runs the identical code. [Inside a Duet Android app](/articles/duet-android-app-anatomy) walks the resulting tree file by file. ## What does a blank project leave open that Duet fixes? A new Android project fixes almost nothing about structure: each screen's author chooses where logic sits, how state is held, and what a test can reach. Duet replaces those per-screen choices with three project-wide ones: * **One feature shape.** Every feature is a serializable state, a sealed set of actions, and one pure [reducer](/articles/duet-glossary#reducer) — no hand-rolled ViewModel variants, no per-screen state idiom. * **Effects as data.** A reducer never calls a repository, reads a clock, or generates an ID. It *returns* effect values; [workers](/articles/duet-glossary#worker) perform them and report back as actions. Anything nondeterministic round-trips through that seam, which is what makes behavior replayable. * **Recordings as the test contract.** Each feature carries a [scenario](/articles/duet-glossary#scenario) — a `Given`/`When`/`Then` script that is compiled into fixture files. The fixtures are build products checked into the repository, and CI replays them byte-for-byte on every platform that implements the feature. ### The same behavior, hand-rolled and fixed A typical hand-rolled ViewModel, and the failure modes a test has to fight: ```kotlin FavoritesViewModel.kt (a blank-project idiom) theme={null} class FavoritesViewModel( private val repo: FavoritesRepository, ) : ViewModel() { private val _ui = MutableStateFlow(FavoritesUiState()) val ui: StateFlow = _ui fun onFavoriteTapped(id: String) { viewModelScope.launch { try { repo.setFavorite(id, favoritedAt = System.currentTimeMillis()) _ui.update { it.copy(favoriteIds = it.favoriteIds + id) } } catch (e: IOException) { _ui.update { it.copy(error = "Couldn't save") } } } } } ``` Testing this class needs a fake repository, a test dispatcher, and control over a clock the code reads inline; the coroutine races the assertion, and the error branch fires only when the fake throws on cue. The same behavior as a Duet feature: ```kotlin FavoritesFeature.kt (commonMain) theme={null} fun favoritesReducer( state: FavoritesState, action: FavoritesAction, ): Reduced = when (action) { is FavoritesAction.FavoriteTapped -> Reduced( state.copy(pending = state.pending + action.id), listOf(Effect.Run(FavoritesEffectPayload.SaveFavorite(action.id)))) is FavoritesAction.SaveSucceeded -> Reduced(state.copy( pending = state.pending - action.id, favoriteIds = state.favoriteIds + action.id)) is FavoritesAction.SaveFailed -> Reduced(state.copy(pending = state.pending - action.id, error = FavoritesError.SaveFailed)) } ``` The timestamp is gone from the logic: if the saved-at time matters, the worker supplies it when it reports `SaveSucceeded`. And the test is no longer a class with fakes — it is a script over the reducer, recorded once and replayed forever: ```kotlin FavoritesScenarioTest.kt theme={null} scenario(feature = "favorites") { given(FavoritesState()) branch("save succeeds") { whenAction("favorite a memory", FavoritesAction.FavoriteTapped("m1")) thenEffects("exactly one save request") { it == effectsOf(Effect.Run(FavoritesEffectPayload.SaveFavorite("m1"))) } whenAction("the worker reports success", FavoritesAction.SaveSucceeded("m1")) then("the favorite is on") { "m1" in it.favoriteIds } } branch("save fails") { whenAction("favorite a memory", FavoritesAction.FavoriteTapped("m1")) whenAction("the worker reports failure", FavoritesAction.SaveFailed("m1")) then("the tap can be retried") { it.pending.isEmpty() && it.error == FavoritesError.SaveFailed } } } ``` Recording this scenario produces a fixture file; the JVM test lane replays it on every push, and when the iPhone app implements the same feature, the identical bytes gate that side too. ## How do the two setups compare? | | A blank Android project | A Duet project | | - | - | - | | Architecture | Chosen per team, drifts per screen | One shape, scaffolded: state + actions + reducer per feature | | Where nondeterminism lives | Wherever it was called — clocks and IDs inline | Behind effects; values round-trip through workers as actions | | The unit of test | A ViewModel plus fakes and test dispatchers | A scenario over a pure function; no fakes, no dispatchers | | What CI checks | The tests each author remembered to write | The recorded corpus, replayed and byte-compared on every push | | A second platform | A second codebase, kept in step by review | The same fixtures gate the iPhone app's identical core | ## What does it cost? The fix has three costs; the [Duet overview](/articles/duet#what-duet-requires-of-you) carries the full list: * **The architecture is fixed at creation.** A Duet project stays a Duet project; the conventions — the feature shape, the effect seam, the recording step — are not per-feature choices. * **Views are still written per platform.** The testability applies to logic. Compose screens are ordinary Compose, tested the way the platform tests UI, and an Android-plus-iOS change was measured at roughly 40% more agent effort than the single-platform equivalent in Modaal's own runs. * **The recording step is part of the loop.** Changing behavior means updating the scenario and re-recording, not just editing code until the app looks right. That is the point — but it is a step a blank project does not have. ## What happens when Android kills your app's process? Android can kill an app's process while it is in the background and recreate the activity from a saved `Bundle` — an asymmetry iOS developers meet on day one of an Android port, documented in [Google's saved-state guidance](https://developer.android.com/topic/libraries/architecture/saving-states). In this architecture the answer is uniform: state is serializable data, so the navigation spine is captured as a value in `onSaveInstanceState` and restored into the rebuilt tree — the [anatomy page](/articles/duet-android-app-anatomy#what-is-in-the-android-app-module) shows the dozen lines in the activity that do it. There is no per-screen inventory of what to save, because there is no per-screen state idiom. ## Why does this suit AI coding agents? An agent building a feature needs machine-checkable gates between its steps, or errors compound silently across a session. The scaffold's loop supplies them in a fixed order: the feature spec, then the scenario, then the recording, then the reducer that must satisfy it, then the screens — with `tools/duet verify` as the same gate CI runs. An agent (or a person) cannot wire a screen to logic that has no passing recording, and a behavior regression fails the build on the commit that caused it rather than surfacing in QA. This staged order is how Modaal's own agent builds every Duet feature — [the Duet overview](/articles/duet#how-a-feature-gets-built) walks the six steps. ## What are the measured results? From Memory Lane, the shipped dual-platform app this site's Duet pages quote, each number with its scope: * **Warm test suite: 1–3 seconds** for the dual-platform behavior suite at four-feature scale during the field migration — fast enough to run on every edit. * **Mutation drill: 10 out of 10.** Ten seeded logic mutations, each caught by fixture replay at the recording layer. * **Recordings byte-identical across platforms: 6 of 6** in the adoption measurement — the Kotlin core replays the exact fixture bytes the Swift reducers recorded. * **Sixteen Android screens, zero logic edits.** The Android app's product surfaces were built against a core whose recordings already passed; no fixture was re-recorded to make Android work. ## When should you not use Duet? Two cases are outside its lane today. Games and drawn playfields: no Duet template yet scaffolds a SpriteKit scene host, and a physics loop is not reducer-shaped work — Modaal's **2D game** template covers that ground, and Duet support for graphics-rich apps is planned. And teams that want to assemble a bespoke stack — their own DI framework, their own test strategy, per-screen architectural freedom — are choosing exactly the degrees of freedom Duet removes; a blank Android Studio project serves that intent better. ## Common questions It is in the same family — unidirectional flow with state as data and actions as the only inputs. What a generic MVI setup does not fix is where nondeterminism lives or what proves behavior: Duet adds the effect seam as a hard rule and recorded fixtures as the contract, replayed on both platforms in CI. A ViewModel test exercises code against fakes you maintain, and it lives on one platform. A scenario drives a pure reducer with no fakes at all, and recording it produces an artifact — the fixture — that both the Android and iPhone implementations must replay byte-for-byte. The test suite is also a cross-platform agreement check, not only a regression net. No. The project is a standard Gradle build; Android Studio opens it, Compose screens are ordinary Compose, and Jetpack libraries live where they always did — in the app module's workers and screens. The constraint is only that feature logic stays in its module and reaches I/O through effects. The state that must survive is serializable and rides the saved-instance Bundle; effects in flight are re-requested by the restored state where the feature models it that way. The scenario can pin that behavior — restoration after a system kill is a branch you record, not a bug class you meet in production. Structurally yes — the Android app and the shared core work with no iOS target. The recording discipline still pays (fast pure tests, a mutation-resistant suite), but the cross-platform gate is Duet's distinctive return; for a product that will only ever ship one platform, weigh the conventions against a lighter setup. [Modaal](https://modaal.dev) scaffolds Duet projects that start from this architecture instead of retrofitting it. ## Sources and further reading * [Guide to app architecture](https://developer.android.com/topic/architecture) — Google's recommendation: UI layer, data layer, unidirectional data flow * [Save UI states](https://developer.android.com/topic/libraries/architecture/saving-states) — what must survive when the system recreates the activity or kills the process, and how * [Test apps on Android](https://developer.android.com/training/testing) — the platform's testing fundamentals this page's approach narrows * [Thinking in Compose](https://developer.android.com/develop/ui/compose/mental-model) — state-driven UI on the rendering side of the seam * [The Duet framework on GitHub](https://github.com/modaal-agent/duet) — both flavors, the recording toolchain and the versioned contracts ## Read next The tree this page's rules produce: feature modules, the thin app module, workers and the fixtures directory. What Duet is, the combinations that scaffold it, and the six-step loop every feature follows. Where the shared/native line sits on the rendering side, and how deliberate divergence is recorded. Reducer, worker, scenario, fixture, the checks — every term defined with one link each. The rules on this page applied by hand: a pure reducer and its recordings in Tutorial 1, the worker harness in Tutorial 4, the mutation drill in Tutorial 6. # Debug Your App by Pointing at the Screen Source: https://docs.modaal.dev/articles/visual-debugging Point at the screen and describe the bug: the agent reads screenshots, console logs, and the live view hierarchy to find the cause, fix it, and verify the fix. Visual bugs — a missing image, a misaligned button, the wrong text on the wrong screen — are the hardest things to describe in code terms. You see them, but you don't know which file or what to check. Modaal closes that gap by letting the agent see and use your running app. You describe what you see, and the agent debugs it the way an engineer would.