This is a comprehensive Kotlin Multiplatform (KMP) project template designed to accelerate the development of production-ready applications. It supports Android, iOS, Desktop (JVM), and Web (WASM) out of the box.
The goal is to provide a minimal-batteries-included starting point. It solves common architectural challenges—such as navigation, persistent settings, and logging—without enforcing a bloated framework, allowing you to focus on your application's unique features.
This project is built on the latest KMP structure compatible with the Android Gradle Plugin (AGP) 9.
- Origin: Generated using the official KMP App Wizard.
- Modernization: Heavily inspired by the watermelonKode/kmp-wizard-template, incorporating migration strategies for AGP 9 and modern multiplatform best practices.
- Libraries: This project also relies heavily on OSKit-KMP and OSKit-Compose-KMP as its core framework foundation. It also uses KmLogging for robust multiplatform logging. These libraries provide solid implementations for common architectural patterns and logging needs.
- Design System: The UI is built on Composables UI (MIT-licensed) and Lucide Icons, not Material. See Design System below.
- Kotlin: 2.4.10
- Gradle: 9.7.1
- AGP Plugin: 9.4.0
- Android compileSdk / targetSdk: 37
- Google Services 4.5.0
- Compose Multiplatform: 1.12.0
- Composables UI: 0.2.0 (with Compose Unstyled 2.7.0)
- Lucide Icons: 2.2.1
- Koin: 4.2.2
- Ktor: 3.5.2
- OSKIT: 5.2.0-rc1
- OSKIT Compose: 4.2.0-rc2
Note on the OSKit release candidates. OSKit 5.2.0-rc1 / Compose 4.2.0-rc2 are built against Kotlin 2.4.0, Compose 1.11.1, Koin 4.2.2 and Ktor 3.5.1 -- the stack this template targets. The last stable release (4.1.1) was built against Compose 1.10.1 and pulls Compose Material 2 onto the classpath. Pin
oskitKmp = "5.1.0"/oskitCompose = "4.1.1"inlibs.versions.tomlif you need stable-only dependencies.
- Migrated to Composables UI: The UI layer now uses Composables UI 0.2.0 (MIT-licensed, free) instead of Material 3. This replaces the old
composables-uibranch, which used the retired paid Composables Core library. - Single project variant: The
main/composables-uisplit is gone. There is one template, and it is Composables UI. Nothing to check out, nothing to keep in sync. - New design system under
composeApp/.../ui/design/: an app-ownedAppThemeplus editableColors,Shapes,TypographyandSpacingtoken files, andAppScreen/AppToolbarshared components. See Design System. - Light/dark theme setting:
ThemeMode(System / Light / Dark) is now a persisted user setting wired throughAppSettingsInteractorinto the theme, with a working toggle on the Home screen. - Dependencies and toolchain updated: Kotlin 2.4.10, Gradle 9.7.1, AGP 9.4.0, Compose Multiplatform 1.12.0, Koin 4.2.2, Ktor 3.5.2, BuildKonfig 0.22.0, OSKit 5.2.0-rc1.
- Fixed
AppInformationServicedependency injection: the platform modules boundAppInformationServiceImplto itself rather than to theAppInformationServiceinterface, so anything injectingAppSettingsInteractorcrashed at startup. It was latent because nothing injected it before. - Fixed the configuration cache for iOS builds:
kmp-app-icon-generatorwiresgenerateIconsonto every Kotlin/Native link task, and that task is not configuration-cache compatible -- which failed every iOS build and:composeApp:allTests. The dependency edge is now cut incomposeApp/build.gradle.kts; run icon generation on demand instead. - Removed dead code: the unused Material 3 theme in
androidApp(ui/theme/) and itscolors.xmlpalette. - Modernized the Gradle DSL: replaced the
by gettingsource-set accessors that Gradle 10 removes. - Platform-native feel: press effects (Material ripple / iOS highlight / desktop hover), haptics, shapes, type scale, spacing, tap targets, toolbar metrics and scrollbars now follow the host platform. See Platform feel.
- Fixed the iOS Xcode build:
ComposeAppis a static framework, so the app target has to linksqlite3itself for OSKit's KV storage. Without it Xcode failed with "symbol(s) not found for architecture arm64" — meaning the README's "open in Xcode and run" never worked.OTHER_LDFLAGSis now part of the generatedConfig.xcconfig. - AI guidelines moved to
CLAUDE.md;GEMINI.mdis now just a pointer to it.
- Updated Dependencies: Bumped Kotlin, Compose Multiplatform, Koin, and Ktor to their latest robust versions (see Version Information for details).
- Fixed Android App Icon Resolution: Removed conflicting default XML adaptive icons (
ic_launcher,ic_launcher_round) from theandroidAppmodule's resources. This ensures Android properly resolves the custom icons generated by the KMP App Icon Generator plugin inside thecomposeAppmodule.
Clone the repo, open it in Claude Code (or any agent that reads CLAUDE.md), and say:
bootstrap the app project according to LAUNCHPAD.md
LAUNCHPAD.md is an executable procedure. The agent will ask you for five things —
Gradle project name, display name, application ID, Kotlin package, and an icon (a file, or a
description for it to generate one) — show you the derived plan for confirmation, then apply the lot:
version catalog, rootProject.name, Kotlin package rename across every module and source set, the
generated Compose Resources package, Android manifest/theme/labels, the iOS xcconfig, platform
icons, and removal of the template's own metadata. It finishes by building every target and running
the app, then deletes itself.
The result is a project dedicated to your app, with no template leftovers — verified by a grep that must come back empty.
- Clone this repository.
- Configure: set
app-name,app-displayName,app-appId,android-namespace,app-namespaceandapp-desktop-entrypointingradle/libs.versions.toml, androotProject.nameinsettings.gradle.kts. - Rename packages:
com.watermelonkode.simpletemplate→ your package incomposeApp, andcom.watermelonkode.androidapp→<your package>.androidinandroidApp. The two Android namespaces must differ; AGP rejects duplicates. - Prune Targets: remove any platform targets you don't need from
composeApp/build.gradle.kts. - Prune Components: remove pre-installed services or components that aren't relevant.
- Rebrand: edit
ui/design/Colors.kt(andShapes.kt/Typography.kt/Spacing.kt). LeaveAppTheme.ktalone — it only binds those values to token names. See Design System. - Icons: replace
composeApp/src/commonMain/composeResources/drawable/icon.svg, then run./gradlew :composeApp:generateIcons --no-configuration-cache. Desktop icons are manual — see Application Icons. - Code: start building your features in
commonMain.
LAUNCHPAD.md documents every one of these steps in full, including the parts that are easy to miss
(the generated resources package changes with rootProject.name; the iOS project file carries a
stale product reference). It is worth reading even if you do the work by hand.
- Project Layout — where each kind of file goes.
- Architecture & Core Concepts — VISCE, plus copy-paste recipes for adding a screen and adding a service.
CLAUDE.md— the authoritative coding conventions: ten non-negotiable rules,Interactorstate patterns, platform-specific code, and a table of common failure modes. Written for AI coding agents, but the reference humans should follow too. Agents pick it up automatically;GEMINI.mdjust points at it.
Before you go far, skim Verifying Changes: theme tokens resolve at composition time and Koin resolves at runtime, so a green compile does not mean working code.
Where things go. data/ implements the interfaces declared in domain/service/; the UI never talks
to data/ directly.
composeApp/src/commonMain/kotlin/com/watermelonkode/simpletemplate/
├── DI.kt # commonModule() + initKoin() + expect platformModule()
├── domain/ # Pure business rules. No framework or platform types.
│ ├── interactor/ # App-wide interactors (AppSettingsInteractor)
│ ├── model/ # Entities, enums, error sealed classes
│ └── service/ # Service INTERFACES only
├── data/ # Infrastructure
│ ├── core/ # Shared plumbing (KtorClient)
│ └── service/ # Service IMPLEMENTATIONS
└── ui/
├── app.kt # App(): AppTheme + RouteSwitch — register screens here
├── router.kt # sealed class Route + deep links
├── coordinator.kt # AppCoordinator — every navigation decision
├── interactor.kt # AppInteractor — app-wide UI state
├── design/ # The design system (see below)
│ ├── AppTheme.kt # binds values to tokens
│ ├── Colors.kt Shapes.kt Typography.kt Spacing.kt # your brand
│ ├── Platform.kt PressEffects.kt Metrics.kt # platform feel
│ └── components/ # AppScreen, AppToolbar, AppScrollbar
└── screen/<feature>/ # <Feature>Screen.kt + <Feature>ScreenViewInteractor.kt
Platform source sets (androidMain, iosMain, desktopMain, wasmJsMain) each hold a
PlatformDI.kt with actual fun platformModule() and actual class PlatformContext, plus any
actual service implementations.
Two naming conventions worth keeping: the four files directly under ui/ are lowercase
(app.kt, router.kt, coordinator.kt, interactor.kt), and platform actuals are suffixed
.android.kt / .ios.kt / .desktop.kt / .wasm.kt.
This project follows the VISCE architecture pattern and utilizes the OSKit-KMP library.
- Reference: VISCE Architecture Documentation
App.kt: The main UI entry point. It initializes theAppInteractorandAppCoordinatorand usesRouteSwitchto render content.AppCoordinator: Manages navigation logic (push, pop, deep links).RouteSwitch: A composable that observes the coordinator and switches screens.
Adding a New Screen — five touchpoints, in this order:
- Route — add to
sealed class Routeinui/router.kt.webRoutePathpowers web URLs and deep links.data object Settings : Route(webRoutePath = "/settings", webRouteTitle = "Settings")
- Coordinator method — in
ui/coordinator.kt. Navigation decisions live only here, never in a screen or a ViewInteractor. Name it after the event, not the destination:fun settingsClicked() = push(Route.Settings)
- ViewInteractor —
ui/screen/settings/SettingsScreenViewInteractor.kt. Holds the screen's state and logic; its public functions mirror UI events (onSaveClicked()), not implementation details (loadData()). - Register it in DI — add
factory { SettingsScreenViewInteractor(get(), get()) }tocommonModule()inDI.kt. This step fails at runtime, not compile time, so it is the one people forget. - Screen +
RouteSwitch— write the composable starting fromAppScreen, then map the route inui/app.kt:TheRouteSwitch(coordinator) { when (it) { Route.Home -> Authorized(state) { HomeScreen() } Route.Settings -> Authorized(state) { SettingsScreen() } is Route.Details -> Authorized(state) { DetailsScreen(it.id) } } }
whenis exhaustive overRoute, so the compiler catches a missing entry here.
A minimal screen looks like this — note that everything visual comes from a token, and the toolbar
and insets come from AppScreen/AppToolbar:
@Composable
fun SettingsScreen(
interactor: SettingsScreenViewInteractor = rememberInject<SettingsScreenViewInteractor>()
) {
val state = interactor.collectAsState()
AppScreen(
toolbar = {
AppToolbar(
title = "Settings",
showBackButton = true,
onBackClicked = { interactor.onBackClicked() },
)
}
) {
Column(
modifier = Modifier.align(Alignment.TopCenter).padding(Theme[spacing][screenPadding]),
verticalArrangement = Arrangement.spacedBy(Theme[spacing][elementPadding]),
) {
Text("Appearance", style = Theme[typography][h3])
Button(
onClick = { interactor.onThemeModeSelected(ThemeMode.Dark) },
style = ButtonStyle.Primary,
) {
Text("Use dark theme")
}
}
}
}Adding a Service — the boundary to the outside world (HTTP, database, sensors):
- Interface in
domain/service/, returningOutcome<Value, Error>. Services must never throw. - Error type in
domain/model/as asealed class. - Implementation in
data/service/, catching everything and mapping DTOs to domain entities. Use the preconfiguredKtorClientfromdata/core/for HTTP — itsget/post/patch/put/deleteextensions already returnOutcome<T, HttpError>and never throw. TranslateHttpErrorinto your own domain error inside the service so transport details do not leak upward. - Register in
DI.ktbound to the interface:single { ProfileServiceImpl(get()) } bind ProfileService::class.
Watch out: binding to the implementation (
bind ProfileServiceImpl::class) compiles, does nothing, and everyget<ProfileService>()then fails at runtime — and only once something first injects it. This exact bug sat undetected in this template until v2.0.
If the implementation needs a platform handle (an Android Context, a file path), make it an
expect class in commonMain/data/service/ and register it in each platformModule() instead.
CLAUDE.md holds the full conventions, including copy-paste versions of these recipes, the
Interactor state patterns and a table of common failure modes. It is written for AI coding agents
but is the authoritative reference for humans too.
The UI is built on Composables UI -- accessible, unstyled components with a token-based theme -- plus Lucide icons. There is no Material theme.
Everything visual is read through one accessor, Theme[property][token]:
import com.composeunstyled.theme.Theme
// Colour, shape, shadow and alpha tokens come from the library:
import com.composables.ui.theme.colors
import com.composables.ui.theme.primaryColor
// Typography and spacing are this app's own theme properties:
import com.watermelonkode.simpletemplate.ui.design.typography
import com.watermelonkode.simpletemplate.ui.design.h1
Text(text = "Title", style = Theme[typography][h1])
Box(Modifier.background(Theme[colors][primaryColor]))Everything lives in composeApp/src/commonMain/kotlin/.../ui/design/:
| File | What to change |
|---|---|
Colors.kt |
Start here. AppLightPalette / AppDarkPalette -- one property per colour token. |
Shapes.kt |
Corner radii, with separate touch and pointer variants. |
Typography.kt |
The type scale (h1..h3, bodyLarge, labelLarge, ...). |
Spacing.kt |
screenPadding, elementPadding, smallPadding. |
AppTheme.kt |
Leave alone -- it only binds the values above to token names. |
AppTheme is a full replacement for the library's own ComposablesTheme, not a wrapper around it.
Compose Unstyled's buildTheme {} replaces the ambient theme wholesale, and the library exposes no
API for overriding one token, so owning the definition is the supported way to control the palette.
Because AppTheme populates the library's own token identities, every stock component
(Button, TextField, AlertDialog, ...) picks up your values automatically.
If a future Composables UI release adds a token that
AppTheme.ktdoes not define,Theme[property][token]throws at composition time with a message naming the missing token. Always run the app after upgrading the library -- a clean compile does not prove the theme is complete.
Composables UI ships no Scaffold, so screens are built from the template's own shared components
in ui/design/components/:
AppScreen(
toolbar = { AppToolbar(title = "Details", showBackButton = true, onBackClicked = { ... }) }
) {
// content, in a Box that already handles safe-area insets
}Components adapt to the current input method via LocalInteractionMode: bigger and rounder under a
finger, tighter under a mouse pointer. Prefer letting them size themselves.
The design system deliberately does not look the same everywhere. One switch,
appPlatform in ui/design/Platform.kt (resolved from OSKit's Platform.current), drives every
platform difference:
| Android | iOS | Desktop / Web | |
|---|---|---|---|
| Press effect | Material ripple | Instant tint, slow fade, no hover | Tint on hover, stronger on press |
| Haptics | Yes | Yes | n/a |
| Buttons | Pill | 12dp rounded rect | 6dp rounded rect |
| Dialogs | 28dp | 14dp | 10dp |
| Toolbar | 56dp, leading title, 22sp | 44pt, centred title, 17sp semibold | 44dp, leading title, 15sp |
| Back icon | Arrow | Chevron | Arrow |
| Body text | 16sp | 17pt | 14–15sp |
| Screen padding | 16dp | 16dp | 24dp |
| Min tap target | 48dp | 44pt | 32dp |
| Scrollbars | System, transient | System, transient | Persistent |
| Cursor | n/a | n/a | Arrow (desktop), hand (web) |
Where it lives:
PressEffects.kt—appPressIndication()returns the platform's press feedback. This is the highest-leverage file: Composables UI components resolve their press effect from the theme's indication tokens, so every stockButton,IconButton,Tabs,NavigationBarItemand menu row becomes native at once, with no component wrapping. On Android it really is the Material ripple —rememberRippleIndicationwrapsandroidx.compose.material.ripple.Metrics.kt— structural sizes (toolbar height, minimum tap target, window padding). These are plain values, not theme tokens, because they describe the platform, not your brand.Platform.kt— theAppPlatformswitch, plusAppFeelfor taste-level toggles. SetAppFeel.HAPTIC_FEEDBACK_ON_PRESS = falseto stop the tap tick.
appPlatform is not the same thing as LocalInteractionMode, which Composables UI uses to describe
the current input device. An Android tablet driven by a mouse is still Android and still gets a
ripple and pill buttons, while its controls may tighten up for a pointer. Use the platform for "what
do users of this OS expect" and interaction mode for "how big should a tap target be right now".
Two things the library keeps from us: Button hardcodes its heights (48dp touch / 36dp pointer) and
applies a 0.98 scale-down on press on every platform. The press effect is fully native, which is
the part users notice, but those two details are uniform. AppToolbar is built from primitives
precisely because Toolbar hardcoded 64dp and a 20sp title the same way.
ThemeMode (System / Light / Dark) is a persisted setting. Read it from
AppSettingsInteractor.state.settings.themeMode and change it with
AppSettingsInteractor.setThemeMode(...); App() feeds it into AppTheme, which crossfades the
colours on change. The Home screen has a working toggle you can copy or delete.
On Material: the app's design system is Composables UI only, and no app code imports
androidx.compose.material*. Compose Material does still appear on the classpath, becauseoskit-composedeclares it as a transitive dependency. That is as far as removal goes without dropping OSKit.
A unified LoggingService is available across all platforms.
// Injection
class MyInteractor(private val logger: LoggingService) : Interactor<MyState>(...) {
fun doSomething() {
logger.debug("Tag") { "Lazy log message" }
logger.error("Tag", exception) { "Error occurred" }
}
}The project uses Koin for Dependency Injection, pre-configured for both common and platform-specific code.
We specifically chose the Koin DSL over annotation-based configuration to:
-
Maintain Maximum Flexibility: Better support for complex dependency setups and conditional registrations.
-
Centralized Visibility: All dependencies and their configurations are clearly visible in a single place (
DI.ktfiles), making the project easier to understand without hunting for annotations or relying on specialized IDE plugins. -
Common DI: Defined in
composeApp/src/commonMain/kotlin/com/watermelonkode/simpletemplate/DI.kt. -
Platform DI: Implemented in platform-specific modules (e.g.,
androidMain,iosMain,desktopMain) to handle platform-specific dependencies.
Use AppSettingsInteractor for UI-related state (user settings + app version).
AppSettingsInteractor: Providessettings(themeMode,muted) andappVersion/buildNumber, plussetThemeMode(...)/setMuted(...)to change them.- Settings are persisted as
AppSettingsDto. Every field in that DTO must have a default, so blobs written by an older build still deserialize after you add a field.
- Settings are persisted as
AppSettingsService: Low-level Key-Value storage for settings.AppInformationService: Low-level provider for platform-specific version metadata.
The gradle/libs.versions.toml file is the single source of truth for configuration.
Modify these in [versions]:
app-name: Application Display Name.app-appId: Bundle ID / Application ID.app-versionName: Semantic version (e.g.,1.0.0).app-versionCode: Build number (Integer).android-namespace/app-namespace: Package namespaces.
Configure signing in androidApp/build.gradle.kts. Credentials are loaded from local.properties (recommended) or gradle.properties.
Keys: android.key.store, android.key.store.password, android.key.alias, android.key.password.
- Android: Drop the google-services.json into
androidAppfolder, then uncomment the google services plugin inandroidApp/build.gradle.kts - iOS: Drop the google-services.plist into
iosApp/iosAppfolder, import it into XCode project, then add Firebase ios SDK as per Firebase manual
- Auto-Generated:
iosApp/Configuration/Config.xcconfigis generated automatically fromlibs.versions.tomlby thesyncIosConfigtask. Do not edit it manually.
For Android and iOS targets, icons are managed by the kmp-app-icon-generator plugin. Other targets
require manual steps:
-
Android and iOS targets:
- Place
icon.svgor 1024x1024icon.pngincomposeApp/src/commonMain/composeResources/drawable/. - Run
./gradlew :composeApp:generateIcons --no-configuration-cache - The task will generate platform icon resources in: Android -
mipmapand iOS -Assets.xcassets. - This is a deliberate on-demand step. The
kmp-app-icon-generatorplugin normally hooksgenerateIconsonto every Kotlin/Native link task, but the task is not configuration-cache compatible, so that broke every iOS build.composeApp/build.gradle.ktscuts that dependency edge -- which is also why the task needs--no-configuration-cachewhen you do run it.
- Place
-
Desktop target:
- Manually replace icons in
composeApp/src/desktopMain/resources/icons/(icon.icns,icon.ico,icon.png).
- Manually replace icons in
- Run:
./gradlew :androidApp:installDebug - Release APK:
./gradlew :androidApp:assembleRelease(Output:androidApp/build/outputs/apk/release/) - Play Store Bundle:
./gradlew :androidApp:bundleRelease
- Run: Open
iosApp/iosApp.xcodeprojin Xcode and run. - Archive/App Store: In Xcode, select "Any iOS Device" -> Product -> Archive.
- Run:
./gradlew :composeApp:run - Package:
./gradlew :composeApp:packageDistributionForCurrentOS- Output:
composeApp/build/compose/binaries/main/(DMG, MSI, or DEB depending on OS). - The Compose plugin's umbrella
:composeApp:packagetask is not configuration-cache compatible; use the task above, or add--no-configuration-cache.
- Output:
- Run:
./gradlew :composeApp:wasmJsBrowserDevelopmentRun - Build:
./gradlew :composeApp:wasmJsBrowserDistribution
Two whole classes of failure in this stack do not show up at compile time, so a green build is not enough:
- Theme tokens resolve at composition time. Reading a token that
AppTheme.ktdoes not define throws on first render with a message naming the token. - Koin resolves at runtime. A missing
factory { }or a service bound to its own class instead of its interface only fails when something first injects it.
So always get the app on screen:
./gradlew :composeApp:compileKotlinDesktop # fastest signal for commonMain
./gradlew :composeApp:allTests # multiplatform tests
./gradlew :composeApp:run # proves tokens and DI actually resolve
./gradlew :androidApp:installDebug # touch sizing, ripple, system insets
./gradlew :composeApp:wasmJsBrowserDevelopmentRunBecause the design system is platform-dependent, a change to ui/design/ should be checked on more
than one target — desktop and Android together cover the pointer and touch paths.
| Symptom | Cause | Fix |
|---|---|---|
There is no <property> property in the AppTheme theme |
A token is read but not defined | Add it to the matching properties[...] map in AppTheme.kt |
NoDefinitionFoundException for a service |
Bound to the implementation, not the interface | single { FooServiceImpl() } bind FooService::class |
NoDefinitionFoundException for a ViewInteractor |
Not registered in commonModule() |
Add a factory { } |
| Settings reset after adding a field | The persisted DTO field has no default | Give every AppSettingsDto field a default |
| Screen looks unstyled | A Material component was imported | Import from com.composables.ui.components.* |
Xcode: symbol(s) not found for architecture arm64 |
sqlite3 not linked into the app target |
./gradlew syncIosConfig — OTHER_LDFLAGS is generated into Config.xcconfig |
Config-cache failure on :composeApp:package |
The Compose plugin's umbrella task | Use packageDistributionForCurrentOS |
Config-cache failure mentioning generateIcons |
The icon plugin holds a Project reference |
Run icon generation with --no-configuration-cache |
If you do not need all platforms, remove them to speed up builds:
- WASM/Desktop: Remove targets from
composeApp/build.gradle.ktsand deletesrc/desktopMain/src/wasmJsMain. - Android: Delete
androidAppfolder, remove fromsettings.gradle.kts, and removeandroidLibraryfromcomposeApp. - iOS: Delete
iosAppfolder and remove iOS targets fromcomposeApp/build.gradle.kts.
We are continuously improving this template. Current priorities include:
- Automated Project Setup: A custom Gradle task to automate initial configuration (App ID/Name) and recursive package renaming.
- Enhanced DI: Full support for the latest Koin Kotlin Compiler plugin for compile-time safety and reduced boilerplate.
Apache-2.0 (see LICENSE).