Kotlin Multiplatform + Compose Multiplatform conventions with Voyager, Koin, Ktor, Kotlinx Serialization
From beenpx claudepluginhub george-popescu/bee-dev --plugin beeThis skill uses the workspace's default tool permissions.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
These standards apply when the project stack is kmp-compose. All agents and implementations must follow these conventions. This is a CROSS-PLATFORM stack targeting Android and iOS.
shared/): Business logic, data layer, networking, DI configuration — shared across all platforms.androidApp/, iosApp/): Platform-specific UI, ViewModels, navigation, storage implementations.data/ (repositories, API, storage) → domain/ (business logic, if needed) → presentation/ (ViewModels, screens).Use expect/actual for platform-specific implementations. Keep the shared API surface minimal:
// commonMain — declare the contract
expect class TokenStorage {
suspend fun getToken(): String?
suspend fun saveToken(token: String)
suspend fun clearToken()
}
// androidMain — Android implementation
actual class TokenStorage(private val context: Context) {
private val prefs = EncryptedSharedPreferences.create(...)
actual suspend fun getToken(): String? = prefs.getString("token", null)
actual suspend fun saveToken(token: String) { prefs.edit().putString("token", token).apply() }
actual suspend fun clearToken() { prefs.edit().remove("token").apply() }
}
// iosMain — iOS implementation
actual class TokenStorage {
actual suspend fun getToken(): String? = NSUserDefaults.standardUserDefaults.stringForKey("token")
// ...
}
Rule: Only use expect/actual when platform APIs genuinely differ. If the implementation is the same on all platforms, keep it in commonMain.
@Composable functions with typed parameters.collectAsState() to observe StateFlows from ViewModels.LaunchedEffect for one-time side effects (load data on first composition).remember and rememberSaveable for local UI state that survives recomposition/rotation.@Composable
fun HomeScreen(viewModel: HomeViewModel = koinViewModel()) {
val uiState by viewModel.uiState.collectAsState()
val isRefreshing by viewModel.isRefreshing.collectAsState()
LaunchedEffect(Unit) { viewModel.loadDashboard() }
when (val state = uiState) {
is HomeUiState.Loading -> LoadingSkeleton()
is HomeUiState.Success -> DashboardContent(
dashboard = state.dashboard,
isRefreshing = isRefreshing,
onRefresh = { viewModel.refresh() },
)
is HomeUiState.Error -> ErrorMessage(
message = state.message,
onRetry = { viewModel.loadDashboard() },
)
}
}
MaterialTheme, Surface, Card, Button, etc.) from androidx.compose.material3.ui/theme/ with Theme.kt, Color.kt, Type.kt.MaterialTheme.colorScheme.primary, .error, .surface) — never hardcode hex colors.isSystemInDarkTheme().modifier: Modifier = Modifier as the first optional parameter to all reusable composables.MutableStateFlow, public StateFlow — standard ViewModel pattern.update {} block for atomic state modifications.Loading, Success(data), Error(message), Empty.class InvoicesViewModel(
private val invoiceRepository: InvoiceRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow<InvoicesUiState>(InvoicesUiState.Loading)
val uiState: StateFlow<InvoicesUiState> = _uiState.asStateFlow()
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
fun loadInvoices(clientId: String) {
viewModelScope.launch {
_uiState.value = InvoicesUiState.Loading
invoiceRepository.getInvoices(clientId)
.onSuccess { _uiState.value = InvoicesUiState.Success(it) }
.onError { _uiState.value = InvoicesUiState.Error(it.message) }
}
}
}
sealed interface InvoicesUiState {
data object Loading : InvoicesUiState
data class Success(val invoices: List<Invoice>) : InvoicesUiState
data class Error(val message: String) : InvoicesUiState
}
Use a custom sealed Result<T> for all repository return types:
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val exception: Throwable, val message: String? = null) : Result<Nothing>()
}
// Extension functions for clean consumption
inline fun <T> Result<T>.onSuccess(action: (T) -> Unit): Result<T> { if (this is Result.Success) action(data); return this }
inline fun <T> Result<T>.onError(action: (Result.Error) -> Unit): Result<T> { if (this is Result.Error) action(this); return this }
// commonMain — shared dependencies
val commonModule = module {
single { Json { ignoreUnknownKeys = true; isLenient = true } }
single { SessionManager() }
single { createHttpClient(get(), get()) }
single { ApiClient(get()) }
// Repositories
single<AuthRepository> { AuthRepositoryImpl(get()) }
single<DashboardRepository> { DashboardRepositoryImpl(get()) }
single<InvoiceRepository> { InvoiceRepositoryImpl(get()) }
}
// androidMain — platform-specific + ViewModels
val appModule = module {
single { TokenStorage(androidContext()) }
single { PreferencesStorage(androidContext()) }
// ViewModels — use factory for parameterized construction
viewModel { HomeViewModel(get(), get(), get()) }
viewModel { params -> LoginPasswordViewModel(params.get(), get()) }
}
single {} for stateless services (repositories, API client, SessionManager).viewModel {} for ViewModels — lifecycle-aware, scoped to the composable.factory {} for objects that should be created fresh each time.koinViewModel() in composables to inject ViewModels.get() in composables — always inject via koinViewModel() or koinInject().fun createHttpClient(json: Json, sessionManager: SessionManager): HttpClient {
return HttpClient {
install(ContentNegotiation) { json(json) }
install(HttpTimeout) {
requestTimeoutMillis = 30_000
connectTimeoutMillis = 15_000
}
install(Auth) {
bearer {
loadTokens { sessionManager.token?.let { BearerTokens(it, "") } }
}
}
HttpResponseValidator {
handleResponseExceptionWithRequest { exception, _ -> /* log */ }
}
}
}
Wrap Ktor with a typed API client:
class ApiClient(private val httpClient: HttpClient) {
suspend inline fun <reified T> get(endpoint: String, params: Map<String, String> = emptyMap()): Result<T> =
safeApiCall { httpClient.get("$baseUrl$endpoint") { params.forEach { (k, v) -> parameter(k, v) } } }
suspend inline fun <reified T, reified R> post(endpoint: String, body: R): Result<T> =
safeApiCall { httpClient.post("$baseUrl$endpoint") { setBody(body) } }
suspend inline fun <reified T> safeApiCall(block: () -> HttpResponse): Result<T> = try {
val response = block()
Result.Success(response.body<T>())
} catch (e: Exception) {
Result.Error(e, e.message)
}
}
@Serializable annotation.@SerialName for snake_case mapping: @SerialName("client_id") val clientId: String.ignoreUnknownKeys = true, isLenient = true.data/api/models/ — separate from domain models if they diverge.@Composable
fun AppNavigator() {
TabNavigator(HomeTab) { tabNavigator ->
Scaffold(
bottomBar = {
NavigationBar {
listOf(HomeTab, InvoicesTab, GatesTab, ProfileTab).forEach { tab ->
NavigationBarItem(
selected = tabNavigator.current == tab,
onClick = { tabNavigator.current = tab },
icon = { Icon(tab.options.icon!!, contentDescription = tab.options.title) },
label = { Text(tab.options.title) },
)
}
}
}
) { innerPadding ->
CurrentTab(Modifier.padding(innerPadding))
}
}
}
object : Tab with TabOptions.Navigator for stack-based push/pop navigation within tabs.navigator.push(DetailScreen(id)) for forward navigation.navigator.pop() to go back.getScreenModel<MyScreenModel>() in Voyager screens.// Contract in commonMain
interface InvoiceRepository {
suspend fun getInvoices(clientId: String): Result<List<Invoice>>
suspend fun getInvoice(id: String): Result<Invoice>
suspend fun payInvoice(id: String): Result<PaymentResult>
}
// Implementation in commonMain (uses shared ApiClient)
class InvoiceRepositoryImpl(private val apiClient: ApiClient) : InvoiceRepository {
override suspend fun getInvoices(clientId: String): Result<List<Invoice>> =
apiClient.get("/clients/$clientId/invoices")
override suspend fun getInvoice(id: String): Result<Invoice> =
apiClient.get("/invoices/$id")
override suspend fun payInvoice(id: String): Result<PaymentResult> =
apiClient.post("/invoices/$id/pay", EmptyBody)
}
FakeInvoiceRepository implementing the interface.runTest {} with StandardTestDispatcher for coroutine testing.Dispatchers.setMain(testDispatcher) in @Before, reset in @After.commonTest/, platform tests in androidTest//iosTest/.class HomeViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val fakeDashboardRepo = FakeDashboardRepository()
@Before
fun setup() { Dispatchers.setMain(testDispatcher) }
@After
fun tearDown() { Dispatchers.resetMain() }
@Test
fun `loadDashboard sets Success state on success`() = runTest {
fakeDashboardRepo.setResult(Result.Success(testDashboard))
val viewModel = HomeViewModel(fakeDashboardRepo)
viewModel.loadDashboard()
advanceUntilIdle()
assertEquals(HomeUiState.Success(testDashboard), viewModel.uiState.value)
}
}
EncryptedSharedPreferences with MasterKey.AES256_GCM for tokens and sensitive data.@Composable functions — ViewModels handle all logic.MaterialTheme.colorScheme tokens.GlobalScope.launch — use viewModelScope for ViewModels, structured concurrency elsewhere.CancellationException — it breaks structured concurrency. Always rethrow it.runBlocking in production code — it blocks the thread. Use suspend functions.MutableStateFlow as public — always expose StateFlow (read-only).Thread.sleep() or blocking I/O on the main thread — use delay() and withContext(Dispatchers.IO).expect/actual for platform APIs — don't ifdef with Platform.OS-style checks.MaterialTheme or StyleSheet-equivalent patterns.Loading and Error states in UI — sealed state must be exhaustive.ScreenModel and AndroidX ViewModel in the same project without clear convention — pick one pattern.commonMain. Networking, repositories, serialization, DI config — all shared.Loading, Success(data), Error(message).Result<T>, never throw exceptions to callers.collectAsState() in composables. Observe ViewModel flows in composables, not manually collecting in effects.LaunchedEffect(key) for load triggers. Load data when a screen composes or when a key changes.viewModelScope for all coroutines. ViewModel coroutines are lifecycle-aware and cancel on clear.update {} for StateFlow mutations. Atomic state updates prevent race conditions.androidMain/iosMain minimal — only expect/actual implementations.libs.versions.toml). Centralize all dependency versions. Never hardcode versions in build files.ErrorMapper, not raw exception messages.LaunchedEffect. Using state values inside LaunchedEffect without proper keys causes callbacks to capture outdated values.@Serializable on DTOs. Kotlinx Serialization fails at runtime if annotation is missing. No compile-time error.CancellationException swallowed. Catching generic Exception in coroutines also catches CancellationException, breaking structured concurrency. Always rethrow.ignoreUnknownKeys in Json config. Backend adds a new field → app crashes on deserialization.commonMain. Using Android-specific APIs in shared code causes iOS build failures.apiClient.get() from a composable — go through ViewModel → Repository → ApiClient.MutableStateFlow instead of StateFlow lets consumers mutate state directly.var for state. Use MutableStateFlow and update {}, not mutable properties.catch (e: Exception) swallows CancellationException. Catch specific types or rethrow cancellation.if (Platform.OS == "android") instead of proper expect/actual declarations.remember { } or ViewModel functions.HomeScreen, InvoiceRepository, HomeViewModel, HomeUiState.loadDashboard(), uiState, isRefreshing.ro.app.data.repository, ro.app.ui.screens.home, ro.app.di.{Feature}ViewModel naming. HomeViewModel, InvoicesViewModel, LoginPasswordViewModel.{Feature}Screen naming. HomeScreen, ProfileScreen, InvoiceDetailScreen.{Entity}Repository + {Entity}RepositoryImpl. Interface + implementation in same package._uiState (private mutable), uiState (public read-only).gradle/libs.versions.toml, referenced in build files.sealed interface for state hierarchies (more flexible, no constructor overhead).When looking up framework documentation, use these Context7 library identifiers:
/websites/kotlinlang_multiplatform — expect/actual, shared code, platform configuration/jetbrains/compose-multiplatform — UI components, state, navigationktor-io/ktor — HTTP client, serialization, authentication, WebSocketInsertKoinIO/koin — DI modules, ViewModel injection, multiplatform setupKotlin/kotlinx.serialization — JSON, @Serializable, @SerialNameadrielcafe/voyager — navigation, tabs, screen models, Koin integrationAlways check Context7 for the latest API — KMP and Compose Multiplatform evolve rapidly between versions.