Status: discovery complete; UI-strategy decision superseded by the Compose Multiplatform plan
Date: 2026-08-15; decision updated 2026-08-22
Scope: current Android app in :app, with Android and iOS as the likely future targets
Decision update: the project will use Compose Multiplatform for shared Android/iOS UI, Koin for dependency injection, and Ktor with
kotlinx.serializationfor shared networking. Experimental Firebase authentication and analytics were removed because the product has no account-backed feature. The staged execution plan and current toolchain baseline are maintained in Compose Multiplatform migration plan. This assessment remains the detailed inventory of Android coupling and migration risks.
KMP is a reasonable fit for this app, but a full rewrite is not the best first move. The app already has a useful separation between domain models, repositories, state holders, and Compose screens. The separation is incomplete, though: several domain/data classes contain Android serialization or Room annotations, repositories are implemented directly with Retrofit/Gson, and the UI owns permissions, location, maps, navigation, resources, intents, and Android lifecycle objects.
Recommended direction:
- Keep
:appas the Android application. - Add a
:sharedKMP library targeting Android and iOS. - Move pure models, state, repository contracts, networking, search/business rules, and favorites persistence into
shared/commonMainin stages. - Keep Android-only UI and integrations in
appuntil an iOS app exists. - Decide later whether the Compose screens should move into Compose Multiplatform or whether iOS should use SwiftUI.
This gives the project a low-risk proof point: the shared module can be consumed by the existing Android app before an iOS UI is introduced. KMP source sets are designed for this split, and platform-specific implementations can be supplied with expect/actual or injected interfaces. See the Kotlin Multiplatform project structure documentation and Android's migration guide for existing projects.
The repository is currently a single Android application module:
:app
└── src/main/java/com/example/recipe_app_compose
├── activities and app startup
├── core/components, navigation, utilities, permissions
├── di
├── features/categories/{data,domain,presentation}
├── features/location/{data,domain,presentation}
└── ui/theme
There are no commonMain, androidMain, iosMain, commonTest, or iOS app source sets today. All Kotlin production code is under app/src/main/java.
The runtime behavior is:
SplashScreenActivity
|
v
OnboardingActivity -- first run only / Preferences DataStore
|
v
MainActivity -- drawer, bottom bar, dialogs, Android navigation
|
+--> TheMealDB repositories --> Retrofit + Gson
+--> Yelp repository ---------> Retrofit + Gson + API key
+--> favorites ----------------> Room database
+--> location/map -------------> Google Play Services + Google Maps Compose
+--> images --------------------> Coil 2 / Android context
The current Android unit test task passes (:app:testDebugUnitTest) with focused coverage for startup routing, Yelp mapping/state/cancellation, location preferences, and recipe saved-state matching. Repository and persistence coverage should continue expanding before extraction.
| Option | What is shared | What remains platform-specific | Assessment for this app |
|---|---|---|---|
| KMP shared logic | Models, repositories, networking, database, state holders | Android Compose UI; SwiftUI on iOS | Recommended first phase. Lowest risk and good architectural value. |
| KMP + Compose Multiplatform | Most state, screens, theme, navigation, and logic | Maps, permissions, external actions, app entry points | Viable later. Requires replacing Android resources/context/navigation APIs. |
| KMP logic + native UIs | Shared logic only; Android Compose and iOS SwiftUI | All UI and platform UX | Best if native iOS UX is important or the team is stronger in SwiftUI. Requires a clean Swift-facing shared API. |
| Keep Android-only | No second target | Everything | Sensible if there is no iOS/product requirement. KMP adds build and library complexity without a second consumer. |
The current codebase does not require KMP to improve the Android app. KMP becomes compelling when the same recipe, search, favorites, location, and API behavior must serve iOS or another Kotlin target.
Files: settings.gradle.kts, build.gradle.kts, app/build.gradle.kts, gradle/libs.versions.toml, gradle.properties
Changes to consider:
- Add a
:sharedmodule and include it fromsettings.gradle.kts. - Apply the Kotlin Multiplatform plugin to
:sharedand configure Android,iosArm64, andiosSimulatorArm64first. Add other iOS targets only when needed. - Keep
com.android.applicationon:app; the application module is still the Android entry point. The shared module should be a library, not an application. - For a new KMP library module, evaluate the official
com.android.kotlin.multiplatform.libraryplugin. Android documents it as the supported plugin for KMP library modules, while the existing Android application remains separate. The plugin has different Android DSL, resource, Java, and test behavior from a normal Android library. See the Android-KMP library plugin documentation. - Move dependencies into source-set-specific buckets. A dependency that is valid for
commonMainmust publish a KMP-compatible variant; Android-only dependencies belong inandroidMainor remain in:app. - Add
kotlinx-coroutines-coretocommonMain; do not exposekotlinx-coroutines-androidfrom shared code. - Add
kotlin.testand coroutine test dependencies tocommonTest. Keep Android instrumentation and Compose UI tests in Android source sets. - Add iOS framework integration and an Xcode
iosAppproject. Decide whether to use direct framework integration, CocoaPods, or another supported integration path. - Recheck Kotlin, AGP, KSP, Room, Compose, and iOS deployment-target compatibility as one matrix. Do not upgrade every library at the same time as the source extraction.
- The current build already reports migration-relevant warnings:
org.jetbrains.kotlin.androidis deprecated with AGP 9+, severalgradle.propertiesflags are deprecated, and the legacy variant API is being used. Clean these warnings before or alongside KMP setup so KMP errors are not hidden by baseline Gradle warnings. android.builtInKotlin=falseandandroid.newDsl=falseare currently pinned ingradle.properties. Revisit them with the AGP/KMP migration guide rather than carrying them forward blindly.- Decide how to generate configuration values for each platform.
BuildConfigis Android-specific and cannot be the shared configuration mechanism.
Possible end state:
settings.gradle.kts
├── include(":app")
├── include(":shared")
└── iosApp/ # Xcode project, not a Gradle application module
shared/
├── build.gradle.kts
└── src/
├── commonMain/
├── commonTest/
├── androidMain/
├── androidHostTest/ # if enabled for the chosen Android-KMP plugin
├── iosMain/
├── iosTest/ # if enabled/configured
├── iosArm64Main/
└── iosSimulatorArm64Main/
Files under features/categories/domain/model, features/location/domain/model, and core/util/Resource.kt
Good candidates for commonMain after cleanup:
Resource- recipe/category/meal/ingredient value objects
LocationData- Yelp response/domain data
RecipeState,RandomMealState,IngredientMealState,DatabaseState, andYelpStates- repository interfaces
- pure search and formatting rules
Required changes:
- Remove
android.os.Parcelableandkotlinx.parcelize.*from all shared models. This affectsCategory,CategoryResponse,Ingredient,IngredientResponse,RandomMeal,RandomMealResponse,LocationData,YelpBusinesses,YelpCategories, andYelpSearchResult. - Remove
java.io.SerializablefromYelpCoordinatesandYelpLocations;java.iois not common code. - Replace navigation-time parceling with a platform-neutral route argument, normally a stable ID such as
category.idCategory.value, and reload/select the object from state. The currentsavedStateHandlestores an entireCategoryas a Parcelable incore/navigation/RecipeApp.kt. - Separate transport DTOs, domain models, and database entities. Today
IngredientandRandomMealare both API-shaped models and UI/database-facing models, whileRandomMealalso carries Room annotations. A cleaner split would beRandomMealDto,RandomMeal, andFavoriteMealEntityor a deliberate shared Room entity. - Replace Gson
@SerializedNamewithkotlinx.serialization@SerialNameif the network layer moves to Ktor. Add@Serializableto DTOs rather than putting serialization annotations on the domain model unless the two shapes are intentionally identical. - Review the inline value classes in
Category.kt. Kotlin value classes are a reasonable common representation, but avoid Android parceling and ensure the generated iOS API is usable. For route and JSON boundaries, plain strings may be simpler. - Consider replacing
UIntin Yelp models and repository parameters withInt/Longplus validation.UIntis legal Kotlin but less natural at a Swift-facing boundary and is unnecessary for ordinary Yelp counts/limits. - Move
YelpBusinesses.displayRating()anddisplayPhoneNumber()out of the domain model. The methods are annotated@Composable, so the domain layer currently depends on Compose. Use a pure formatter in common code or format in each UI layer. - Make nullable API fields and error semantics explicit. Decide whether missing Yelp address fields should be represented as nullable values, empty strings, or a normalized domain type.
- Normalize API error types.
Resource.Errorcurrently stores only a string; a shared layer will benefit from a stable error category such as network, unauthorized, rate-limited, server, decoding, and unknown.
Files: features/categories/data/datasources/remote/api/ApiService.kt, RecipeRepositoryImpl.kt, RetrofitInstance.kt, SafeApiCaller.kt, and the corresponding Yelp files
The repository contracts are good extraction seams, but the implementations are Android/JVM-shaped:
- Retrofit interfaces and
retrofit2.Responseshould not be exposed fromcommonMain. - Gson conversion and
GsonConverterFactoryare not the desired common serialization layer. OkHttpClient,HttpLoggingInterceptor, andjava.util.concurrent.TimeUnitare currently configured in Android-oriented singleton objects.Constants.BASE_URL,YELP_BASE_URL, andYELP_API_KEYare obtained from AndroidBuildConfig.
Recommended shared replacement:
- Use a KMP HTTP client such as Ktor Client in
commonMain, withContentNegotiationandkotlinx.serialization. - Provide an Android engine in
androidMainand a Darwin engine iniosMain; Ktor documents this source-set pattern for Android/iOS clients. See Ktor client engines and Ktor's KMP client setup. - Keep one shared request/repository implementation and inject the client, base URLs, auth header provider, and logger.
- Replace the two Retrofit singletons with one configurable shared
ApiClientor two typed API clients that share a configured HTTP client. - Configure JSON with unknown-key tolerance because third-party API payloads can add fields. Add explicit tests for all response shapes.
- Move retry, timeout, cancellation, and error mapping into a common policy. Preserve cancellation rather than converting it to a user-facing error.
- Preserve the current release policy that disables HTTP logging and the Yelp client's authorization-header redaction. Review whether debug body logging is appropriate before distributing debug builds.
- Decide how Yelp authentication works on iOS. A client-embedded Yelp key is extractable from any mobile binary; the safer design is a server-side proxy or a tightly restricted service credential.
An Android-only interim path is also valid: leave Retrofit in app and first move only models and repository interfaces to shared. That is useful if the team wants to validate the KMP module before replacing network code.
Files: RandomMeal.kt, RandomMealDAO.kt, RandomMealDatabase.kt, DatabaseRepositoryImpl.kt, and DependencyInjector.kt
Room is no longer automatically an Android-only decision: the official Room KMP documentation says Room supports KMP from version 2.7.0, and this project currently declares 2.8.4. That makes shared favorites persistence plausible. See Set up Room Database for KMP.
Changes still required:
- Move the entity and DAO into a shared data source only after confirming the exact Room/KSP versions for Kotlin 2.4.0 and the chosen KMP plugin.
- Add the KMP Room runtime and SQLite driver dependencies in the correct source sets. The KMP setup uses
androidx.sqlite:sqlite-bundledand a platform-specific database builder/factory. - Replace the Android-only
Room.databaseBuilder(context, ...)call with a platform-specific database factory. Android usesContext; iOS needs a file path and native storage setup. - Keep the database factory out of common code using an injected
DatabaseFactoryorexpect/actual. - Review the current destructive migration policy.
fallbackToDestructiveMigration()can delete a user's favorites during schema changes; define real migrations before shipping cross-platform persistence. - Preserve the repository's non-suspending
getMeals(): Flow<List<...>>observation contract and suspend write operations when it moves to shared code. - Decide whether the same database schema must be compatible across Android and iOS. If yes, add migration tests and test both drivers.
- Keep Room annotations in a data/entity package. If the same domain object is used by SwiftUI, do not make the public domain model depend on Room.
An alternative is SQLDelight or a small key-value store. Room is attractive here because the existing schema is tiny and the migration surface is already familiar, but SQLDelight can produce more explicit multiplatform SQL and Swift-friendly APIs.
Files: RecipeViewModel.kt, DatabaseViewModel.kt, LocationViewModel.kt, YelpViewModel.kt, DependencyInjector.kt, and RandomMealApp.kt
The state flows are strong candidates for common code, but the current classes have Android lifecycle and global dependency coupling:
androidx.lifecycle.ViewModel,viewModelScope, andandroidx.lifecycle.viewmodel.compose.viewModel()are used directly.- Constructor defaults read from
DependencyInjector, which hides dependencies and requires the Android application to initialize first. DependencyInjectoris an Android singleton that usesContext, Room, and lazy global repositories.RandomMealAppis an AndroidApplicationstartup hook.- Several composables instantiate ViewModels manually rather than obtaining them from a lifecycle owner. This can create duplicate state holders and scopes.
Recommended changes:
- Inject repositories, database stores, location services, connectivity monitors, and external actions explicitly. Remove production constructor defaults that reach into a global singleton.
- Move the shared state machines/use cases to common code. They can expose
StateFlowand suspend commands without knowing whether the consumer is Compose or SwiftUI. - Choose one lifecycle strategy:
- use the KMP-capable AndroidX ViewModel artifact for shared ViewModels; current lifecycle versions are in the range where KMP ViewModel support is available, or
- use platform-neutral state holders with an injected
CoroutineScope, then let Android and iOS own lifecycle cancellation.
- If SwiftUI consumes the shared flows, plan how flows are bridged to Swift observation/Combine. If Compose Multiplatform is used, common Compose can collect them directly.
- Create an explicit
AppDependencies/AppContainerfactory per platform. Android can construct it fromApplication; iOS can construct it from the app delegate or Swift entry point. - Keep navigation and screen-specific state in the UI layer. Shared state should not know about
NavHostController,Activity,Context, orSavedStateHandleunless the chosen KMP ViewModel design specifically requires it.
Current behavior worth revisiting while extracting:
- The dish-search response model and related route/state names still use
Ingredient, even though the endpoint searches complete meals by name. Rename the model and APIs when moving them so the shared contract describes the returned data accurately. - The repository implementations never return
Resource.Loading, so the loading branches in the ViewModels are currently unreachable. Either emit loading from a use case or model request state directly. fetchCategories,fetchRandomMeal, andfetchIngredientsstart separate fire-and-forget jobs fromRecipeViewModel.init. During migration, make startup concurrency, cancellation, and failure isolation explicit and testable.
Files: PermissionUtils.kt, PermissionsRequestLauncher.kt, LocationViewModel.kt, GoogleLocationSelectionScreen.kt, YelpScreen.kt, and AndroidManifest.xml
This is the clearest platform boundary in the project. PermissionUtils currently combines five concerns:
- Android runtime permission checks and launchers.
- Google Play Services fused-location callbacks.
- Android
Geocoderreverse geocoding. - Android
ConnectivityManagermonitoring. - A Compose
rememberConnectivityState()adapter.
Refactor toward common interfaces:
commonMain
├── LocationProvider -> Flow<LocationData>
├── PermissionState/Requester -> platform UX
├── ReverseGeocoder -> suspend fun addressFor(LocationData)
├── ConnectivityMonitor -> Flow<NetworkConnectionState>
└── MapLocation model -> plain coordinates and display data
androidMain
├── FusedLocationProviderClient
├── ActivityResultContracts
├── ConnectivityManager
├── Geocoder
└── Google Maps Compose
iosMain / iosApp
├── CoreLocation permissions and updates
├── NWPathMonitor connectivity
├── CLGeocoder reverse geocoding
└── MapKit or Google Maps iOS SDK
Specific changes:
- Do not put
Context,PackageManager,Looper,LatLng,FusedLocationProviderClient, orConnectivityManagerin common code. - Keep permission request UI in each platform app. A shared state machine can report
Unknown,Denied,Restricted, andGranted. - Android's
ACCESS_NETWORK_STATE,ACCESS_WIFI_STATE, andINTERNETare manifest declarations, not runtime permissions in the same sense as location. The currentRequestNetworkPermissions()path should be reviewed rather than ported literally. - Add iOS
Info.plistlocation usage descriptions and configure the desired authorization level. Add any required network, URL-scheme, and map configuration in the iOS app target. - The
GoogleLocationSelectionScreencannot be moved unchanged to common UI because it imports Google Maps Android Compose types. Keep it Android-specific, use MapKit or Google Maps iOS in native UI, or build a Compose Multiplatform map adapter with separate actual implementations. LocationViewModelshould not callandroid.util.Log; use a common logger interface or a platform logger.- Reverse geocoding is asynchronous on some platforms. Change the current synchronous
Geocoder.getFromLocation()contract to a suspend function and model failures. - Ensure location callbacks are removed when the screen/view model is disposed. The current location update registration does not show a matching removal path.
Authentication and analytics are intentionally absent from the current application. Firebase was an Android learning experiment rather than support for an account-backed product capability, so its activities, screen, configuration files, Gradle plugin, and dependencies were removed before migration.
Do not add an auth abstraction to commonMain preemptively. If a future requirement such as cross-device favorite synchronization establishes a real account need, evaluate providers and platform policy at that time and add authentication as its own vertical slice.
Files: MainActivity.kt, SplashScreenActivity.kt, core/components/Widgets.kt, core/navigation/*, all files under presentation/view, ui/theme/*, app/src/main/res/*
The layout and Material styling are the most reusable part of the UI, but the current screens are not common-ready.
Android-specific dependencies/usages to isolate:
ComponentActivity,setContent,enableEdgeToEdge, and the Android activity lifecycle.LocalContext,Intent,Toast,packageManager, andActivity.finish().R.string,R.drawable, Android XML themes, and Android launcher resources.androidx.navigationNavHostController,SavedStateHandle, and Parcelable route objects.androidx.lifecycle.viewmodel.compose.viewModel()and lifecycle-aware collection decisions.androidx.activity.compose.rememberLauncherForActivityResult().- Coil 2's Android-oriented
ImageLoaderconstruction. - Google Maps Compose.
If sharing UI with Compose Multiplatform:
- Use a Compose Multiplatform-compatible plugin/dependency set rather than assuming the Android Compose BOM is enough.
- Replace
Raccess and Android XML resources with a multiplatform resource strategy for strings, images, and other assets. Android launcher icons, manifest resources, backup rules, and XML themes remain in the Android app. - Replace
LocalContext-based image loader creation with a shared image-loading abstraction. Coil 3 supports Compose Multiplatform; its documentation recommends a Ktor network backend for multiplatform rendering. See Coil's multiplatform setup. - Replace
NavHostControllerwith a common destination model and a navigation implementation that supports the selected targets. Alternatively, keep navigation platform-specific and pass callbacks into shared screens. - Use
collectAsStateor a common lifecycle-aware approach wherecollectAsStateWithLifecycleis not available in the selected common source set. - Move
AppTheme, typography, colors, and Material components only after confirming their Compose Multiplatform availability. Keep platform-specific system bars, splash behavior, and window configuration in the platform app. - Move
ExternalLinkTextbehind anExternalUriHandlerabstraction if it must be shared.LocalUriHandlermay be available in a chosen common UI stack, but email/share actions still need platform adapters. - Replace
painterResourceand Android drawable references in the splash screen with common resources or platform-specific splash screens.
If keeping native iOS UI:
- Leave the existing Android Compose screens in
:app. - Expose small, stable shared use-case/state APIs to SwiftUI rather than exporting Android Compose types.
- Keep a platform-specific screen for maps and permission flows.
- Decide whether the iOS app should reproduce the Android drawer/bottom-bar UX or use native iOS navigation and tab patterns.
Navigation-specific cleanup in RecipeApp.kt:
- Make
CategoryScreena common destination model with noNavHostControllerdependency. - Pass a category ID or serialized route argument instead of a Parcelable
Categoryobject. - Make screen state ownership explicit. The current app can create multiple
RecipeViewModelandDatabaseViewModelinstances in nested composables, which will matter even more when Android and iOS have different lifecycle models.
Files: screen files using rememberAsyncImagePainter, core/components/Widgets.kt, gradle/libs.versions.toml
- Replace Coil 2 imports with Coil 3 KMP if sharing Compose UI, or keep Coil Android in
:appand use a native iOS image loader. - Continue using a single image loader. Unused Glide and its annotation processor were removed, leaving Coil as the Android implementation.
- Standardize image loading around URL strings and a shared placeholder/error policy.
- Move the bundled
dining_two.webpand launcher assets into the appropriate common resource or platform asset locations. The obsoletedinner.pnglauncher was removed. - The splash screen should be implemented as an Android splash/theme and an iOS launch screen rather than assuming one
SplashScreenActivitycan be shared.
Files: app/build.gradle.kts, core/util/Constants.kt, app/src/main/AndroidManifest.xml, and local local.properties
Changes and risks:
BuildConfigvalues are generated only for the Android app. Create a sharedApiConfiginterface and inject a platform/build-specific implementation.- Do not copy the Android
local.propertiesloading code into common code. It depends onjava.util.Properties, Gradle project files, and Android BuildConfig generation. - The Google Maps key is injected from
local.propertiesthrough a manifest placeholder. Keep it restricted by Android package/SHA-1 and add a separately restricted key for the iOS bundle ID. - The Yelp key is injected from
local.properties, but any key shipped in an Android or iOS client can be extracted. Prefer a backend proxy; otherwise use the provider's restrictions and redact it from logs. - Add iOS configuration equivalents: map key setup, API base URL configuration, bundle IDs, signing settings, URL schemes, and privacy usage descriptions.
- Review release configuration before adding an iOS target: minification, debug logging, symbolication, crash reporting, analytics consent, and per-platform environment values.
Files: tests under app/src/test/... and app/src/androidTest/...
The generated placeholder tests have been removed. The project now has focused tests for startup routing, primary navigation, Yelp request mapping, Yelp state/cancellation behavior, phone formatting, and the offline fallback. Expand this baseline before extraction so Android behavior can be compared with shared behavior.
Recommended test layers:
shared/src/commonTest: model normalization,Resourcebehavior, search matching, validation, error mapping, request/use-case state transitions, and repository tests with fake data sources.- Ktor
MockEnginetests: TheMealDB and Yelp success, HTTP errors, malformed payloads, cancellation, auth headers, and query parameters. - Room KMP tests: insert, replace, observe, delete-one, delete-all, schema migration, and destructive-migration policy. Run against the Android and iOS-supported drivers where practical.
- Android tests: permission launcher, connectivity monitor, Google Maps/location adapter, Android navigation, and intent/share behavior.
- iOS tests: CoreLocation permission states, reverse geocoder, connectivity monitor, and map presentation adapter.
- Compose tests per UI target for loading, empty, error, search, favorite, and navigation flows.
- Add contract tests so Android and iOS implementations produce the same common error/state semantics.
- Add tests for lifecycle cancellation and duplicate requests; several current flows launch nested jobs and delay network calls.
Minimum migration gate for each extracted slice:
- Existing Android unit/instrumented behavior remains green.
- The shared module compiles for Android and iOS simulator.
- The shared test suite runs without network access.
- No common source imports Android, Java-only, Compose UI, Google Play Services, or Android resources.
- The Android app consumes the shared implementation through interfaces, not global service locators.
- A small iOS spike can call the shared repository/state API before the next slice is extracted.
shared/commonMain
├── core
│ ├── Result/Error/Resource
│ ├── configuration (interfaces, no BuildConfig)
│ └── logging (interface)
├── features/recipes
│ ├── domain/model
│ ├── domain/repository
│ ├── domain/usecase
│ └── data
│ ├── remote (Ktor + kotlinx.serialization)
│ └── local (Room KMP or SQLDelight)
├── features/location
│ ├── model
│ ├── repository/contracts
│ └── usecase
└── presentation
└── state holders or KMP ViewModels
shared/androidMain
├── Room database builder
├── Ktor Android engine
└── Android logger/configuration
shared/iosMain
├── Room/SQLite or SQLDelight iOS database factory
├── Ktor Darwin engine
└── iOS logger/configuration
app
├── Android activities/manifest/resources
├── Android Compose screens, or Compose Multiplatform UI entry point
└── permission/location/map adapters
iosApp
├── SwiftUI or Compose Multiplatform root
├── Info.plist and launch assets
└── iOS permission/location/map adapters
- Rotate/restrict the committed Maps key and remove credentials from logs.
- Add repository, model, search, ViewModel/state, and favorites tests.
- Fix the stale-list and Yelp-filter issues before moving code.
- Extract pure search/formatting functions from composables and models.
- Keep the dependency graph intentional; Glide and the complete experimental Firebase stack have already been removed because the app did not use them.
- Clean deprecated Gradle/Kotlin flags and record the supported toolchain.
- Create
:sharedwith Android and iOS targets. - Move
Resource, plain models, state classes, and repository interfaces. - Make
:appdepend on:shared. - Keep Android implementations and UI working through adapters.
- Compile shared code for an iOS simulator as an empty/low-risk integration proof.
- Introduce serializable DTOs and domain mapping in common code.
- Replace Retrofit/Gson with Ktor/kotlinx.serialization in common code, or temporarily keep an Android Retrofit adapter while the iOS client is prototyped.
- Add MockEngine and response/error contract tests.
- Inject API configuration and remove
Constants' directBuildConfigdependency.
- Choose Room KMP or SQLDelight.
- Split entity from domain model.
- Add platform database factories and real schema migrations.
- Move the favorites repository and test it against both target drivers.
- Move state holders/use cases to common code and expose a Swift-friendly API if using SwiftUI.
- Move the onboarding completion contract while keeping platform DataStore construction explicit.
- Preserve the direct onboarding-to-main startup policy on both platforms.
- Native UI path: build iOS SwiftUI screens against the shared API and keep Android Compose unchanged.
- Shared UI path: migrate resources, navigation, image loading, lifecycle collection, and platform actions to Compose Multiplatform; leave maps and permission surfaces behind platform adapters.
- Implement Android/iOS location, connectivity, reverse geocoding, maps, external URL/share, logging, and configuration adapters.
- Add iOS privacy strings, map setup, signing, and release configuration.
- Run Android and iOS functional parity checks for onboarding, recipe browsing, ingredient search, Yelp search, map selection, favorites, and directions.
Proceed with a shared-logic KMP spike, not a full UI migration. The highest-value shared slice is:
plain models
-> Ktor/kotlinx.serialization repositories
-> recipe/Yelp use cases and search logic
-> StateFlow state holders
-> Android app first, iOS simulator second
Room KMP is now a credible option for favorites, but it should follow the networking/model extraction rather than lead the migration. Maps, permissions, reverse geocoding, external actions, and app startup should remain behind platform adapters. Compose Multiplatform should be a separate product/UX decision, because it changes resource, navigation, lifecycle, and platform-integration strategy beyond simply adding KMP.