Dependency Injection
We use Koin with auto-annotation for efficient and scalable dependency injection.
1. Create a Module
To create a new Koin module, define a class annotated with @Module and
@ComponentScan. This tells Koin to scan the specified package for annotated
components.
@Module
@ComponentScan("dev.cmpose.multiplatform.feature.home.presentation")
class HomePresentationModule
2. Register the Module
Add your module to the initKoin function in the composeApp module. Use
buildList to combine it with other modules.
fun initKoin(config: KoinAppDeclaration? = null) {
startKoin {
config?.invoke(this)
val modules = buildList {
add(HomePresentationModule().module)
}
modules(modules)
}
}
3. Auto-Annotation
Once your module is set up, you can use Koin annotations to automatically register your classes. Common annotations include:
@Single: Creates a singleton definition.@Factory: Creates a factory definition (new instance each time).@KoinViewModel: Registers a ViewModel.
Network Architecture
Learn how to handle network requests with type-safe state management using Ktor and Flow.
1. RestResult - State Management
We use a sealed class RestResult to represent the three states of a network
request:
Success, Error, and Loading. This provides
type-safe state handling throughout your app.
sealed class RestResult {
class Success(
val result: T
) : RestResult()
data object Error : RestResult()
class Loading(val loading: Boolean) : RestResult()
}
2. BaseRepository - Request Handler
The BaseRepository provides a reusable request function that wraps
HTTP calls, handles exceptions, and returns a RestResult. All repositories extend
this base class.
open class BaseRepository {
suspend inline fun request(
crossinline call: suspend () -> HttpResponse
): RestResult = withContext(context = Dispatchers.IO) {
try {
call.invoke().asRestResult()
} catch (_: Exception) {
RestResult.Error
}
}
}
3. Mapping Success Results
When you need to transform the response model (e.g., map API response to domain model), use the
mapOnSuccess extension. It only transforms Success results, leaving
Error and Loading unchanged.
inline fun RestResult.mapOnSuccess(map: (T?) -> R): RestResult = when (this) {
is RestResult.Success -> RestResult.Success(map(result))
is RestResult.Error -> this
is RestResult.Loading -> this
}
4. Repository Implementation
Here's how to implement a repository. Use @Single to register it with Koin, extend
BaseRepository, and use mapOnSuccess to transform responses.
@Single(binds = [GithubRepository::class])
internal class GithubRepositoryImpl(
private val httpClient: HttpClient
) : GithubRepository, BaseRepository() {
override suspend fun getUsers(username: String): RestResult> =
withContext(Dispatchers.IO) {
return@withContext request {
httpClient.request { reqUsers(username = username) }
}.mapOnSuccess {
githubUserListMapper(response = it)
}
}
}
5. UseCase with buildDefaultFlow
UseCases call repositories and wrap the result in a Flow. The buildDefaultFlow
extension automatically emits Loading(true) at the start,
Loading(false) on completion, and catches errors.
@Factory
class SearchGithubUsersUseCase(@Provided private val repository: GithubRepository) {
operator fun invoke(username: String): Flow>> = flow {
val result = repository.getUsers(username = username)
emit(result)
}.buildDefaultFlow(Dispatchers.IO)
}
buildDefaultFlow Extension
This extension automates loading state management. It emits Loading(true) when the
flow starts, catches any errors and emits Error, and finally emits
Loading(false) when complete.
inline fun Flow>.buildDefaultFlow(
dispatcher: CoroutineDispatcher,
loading: Boolean = true
): Flow> {
return this.onStart {
emit(RestResult.Loading(loading))
}.catch { _ ->
emit(RestResult.Error)
}.onCompletion {
emit(RestResult.Loading(false))
}.flowOn(dispatcher)
}
6. ViewModel - Handling States
In the ViewModel, use onSuccess, onError, and onLoading
extensions to handle each state. These extensions let you update the UI state based on the
network result.
@KoinViewModel
internal class OnboardingViewModel(
@Provided private val searchGithubUsers: SearchGithubUsersUseCase
) : CoreViewModel() {
private val _uiState = MutableStateFlow(OnboardingUiState())
val uiState: StateFlow
get() = _uiState
fun searchUser() {
safeFlowApiCall {
searchGithubUsers.invoke(username = _uiState.value.query)
}.onSuccess { response ->
_uiState.update { state ->
state.copy(users = response)
}
}.onLoading { isLoading ->
_uiState.update { state ->
state.copy(isLoading = isLoading)
}
}.onError {
_uiState.update { state ->
state.copy(isError = true)
}
}.launchIn(viewModelScope)
}
}
7. Flow Extensions for State Handling
These three extensions (onSuccess, onError, onLoading)
allow you to handle each RestResult state in a clean, functional way. They use
transform to react to specific states while still emitting all results downstream.
fun Flow>.onSuccess(action: suspend (T) -> Unit): Flow> {
return transform { restResult ->
if (restResult is RestResult.Success) {
action.invoke(restResult.result)
}
emit(restResult)
}
}
fun Flow>.onError(action: suspend () -> Unit): Flow> {
return transform { restResult ->
if (restResult is RestResult.Error) {
action.invoke()
}
emit(restResult)
}
}
fun Flow>.onLoading(action: suspend (Boolean) -> Unit): Flow> {
return transform { restResult ->
if (restResult is RestResult.Loading) {
action.invoke(restResult.loading)
}
emit(restResult)
}
}
How It Works
- onSuccess: Executes the action when a
Successresult is emitted, typically used to update UI state with data. - onError: Executes the action when an
Errorresult is emitted, useful for showing error messages. - onLoading: Executes the action when a
Loadingresult is emitted, used to show/hide loading indicators.
All three extensions use transform to perform side effects while still emitting the
original result, allowing you to chain multiple handlers together.
Code Quality with Detekt
Enforce code quality and consistency using Detekt static code analysis for Kotlin.
What is Detekt?
Detekt is a static code analysis tool for Kotlin that helps you maintain code quality by detecting code smells, complexity issues, and potential bugs. You can customize rules to match your team's coding standards.
Custom Rules Configuration
You can upload your custom detekt.yml configuration file during project setup. If
you skip this step, you can always add or modify rules later by locating the
detekt.yml file in your project and editing it directly.
build:
maxIssues: 0
style:
MagicNumber:
active: true
MaxLineLength:
active: true
maxLineLength: 120
Implementation
There are two ways to use Detekt in your project: through convention plugins (recommended) or manual implementation.
Option 1: Convention Plugins (Recommended)
If you use the project's convention plugins (like KmpLibraryConventionPlugin,
FeatureConventionPlugin, etc.), Detekt is already configured and applied
automatically. These convention plugins have Detekt built-in, so you don't need to
do anything extra.
Simply apply the appropriate convention plugin to your module's build.gradle.kts,
and Detekt will work out of the box:
plugins {
alias(libs.plugins.kmpLibrary) // Convention plugin with Detekt already included
}
The convention plugins internally apply the Detekt configuration, so there's no additional setup required.
Option 2: Manual Implementation
If you prefer not to use convention plugins or need to add Detekt to a module that doesn't use them, you can manually apply the Detekt plugin:
plugins {
// Other plugins...
}
apply("com.kturker.multiplatform.detekt")
Running Detekt
To analyze your code with Detekt, run the following Gradle command from your project root:
./gradlew detekt
Detekt will analyze all modules with the plugin applied and generate a report showing any code quality issues, violations, and suggestions for improvement.
Data Persistence with DataStore
Learn how to implement cross-platform data storage using DataStore with the expect/actual pattern.
What is DataStore?
DataStore is a data storage solution that allows you to store key-value pairs asynchronously. In Compose Multiplatform, we use the expect/actual pattern to handle platform-specific implementations for iOS and Android.
1. Common Implementation
In your commonMain source set, define the expected platform-specific function and
the shared factory function:
expect fun getPlatformDataStore(): DataStore
fun createDataStore(
producePath: () -> String,
): DataStore = PreferenceDataStoreFactory.createWithPath(
corruptionHandler = null,
migrations = emptyList(),
produceFile = { producePath().toPath() },
)
internal const val dataStoreFileName = "multiplatform.preferences_pb"
You can customize the dataStoreFileName to match your app's naming convention. The
createDataStore function is a helper that both platforms will use.
2. Android Implementation
In your androidMain source set, provide the actual implementation using the Android
Context:
fun createAndroidDataStore(context: Context): DataStore =
createDataStore(
producePath = { context.filesDir.resolve(dataStoreFileName).absolutePath }
)
private lateinit var dataStore: DataStore
actual fun getPlatformDataStore(): DataStore {
if (::dataStore.isInitialized.not()) {
val context: Context = getKoin().get()
dataStore = createAndroidDataStore(context)
}
return dataStore
}
On Android, we lazily initialize the DataStore using the application Context retrieved from Koin. The file is stored in the app's internal files directory.
3. iOS Implementation
In your iosMain source set, provide the actual implementation using iOS file system
APIs:
@OptIn(ExperimentalForeignApi::class)
private fun createIOSDataStore(): DataStore = createDataStore(
producePath = {
val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null,
)
requireNotNull(documentDirectory).path + "/$dataStoreFileName"
}
)
private val dataStore: DataStore by lazy {
createIOSDataStore()
}
actual fun getPlatformDataStore(): DataStore = dataStore
On iOS, we use NSFileManager to locate the Documents directory and store the
DataStore file there. The instance is lazily initialized using Kotlin's lazy
delegate.
4. Usage Example
Once the platform-specific implementations are in place, you can use DataStore in your common code by injecting it through Koin:
class DarkModeManagerImpl(
@Provided private val dataStore: DataStore
) : DarkModeManager {
override suspend fun setDarkMode(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[DARK_MODE_KEY] = enabled
}
}
override fun isDarkModeEnabled(): Flow {
return dataStore.data.map { preferences ->
preferences[DARK_MODE_KEY] ?: false
}
}
companion object {
private val DARK_MODE_KEY = booleanPreferencesKey("dark_mode")
}
}
From here, you can use standard DataStore APIs to read and write preferences. The platform-specific storage handling is completely abstracted away.
Dynamic Theming
Implement Dark/Light mode with system preference support using DataStore and cross-platform theme detection.
Overview
The theming system is built on top of DataStore, so if you select theming when creating a blank project, DataStore will be automatically implemented. Users can choose between Light, Dark, or System theme modes, with preferences persisted across app sessions.
1. DarkModeManager Interface
The theming system is managed through a singleton DarkModeManager interface with
three key methods:
interface DarkModeManager {
@Composable
fun isSystemDarkMode(): Flow
fun getThemeMode(): Flow
suspend fun setThemeMode(mode: ThemeMode)
}
2. Implementation with DataStore
The implementation uses DataStore to persist the user's theme preference and provides reactive flows for observing theme changes:
@Single(binds = [DarkModeManager::class])
class DarkModeManagerImpl(
@Provided private val dataStore: DataStore
) : DarkModeManager {
private val isDarkMode = intPreferencesKey("themeMode")
@Composable
override fun isSystemDarkMode(): Flow {
val isSystemInDarkTheme = ObserveSystemIsDarkTheme()
return dataStore.data.map { preferences ->
when (ThemeMode.fromValue(preferences[isDarkMode])) {
ThemeMode.DARK -> true
ThemeMode.LIGHT -> false
ThemeMode.SYSTEM -> isSystemInDarkTheme
}
}
}
override fun getThemeMode(): Flow {
return dataStore.data.map { preferences ->
ThemeMode.fromValue(preferences[isDarkMode])
}
}
override suspend fun setThemeMode(mode: ThemeMode) {
dataStore.edit { preferences ->
preferences[isDarkMode] = mode.ordinal
}
}
}
3. ThemeMode Enum
The ThemeMode enum represents the three available theme options:
enum class ThemeMode {
LIGHT,
DARK,
SYSTEM;
companion object {
fun fromValue(ordinal: Int?): ThemeMode {
return entries.find { it.ordinal == ordinal } ?: SYSTEM
}
}
}
How It Works
- setThemeMode(): Saves the selected theme mode to DataStore using the enum's ordinal value
- getThemeMode(): Returns a Flow of the current theme mode for real-time updates in settings screens
- isSystemDarkMode(): Returns a Flow
for app-wide theme application, respecting system preference when SYSTEM mode is selected
4. System Theme Detection
To detect the system theme, we use the expect/actual pattern. This is necessary because iOS requires a custom implementation to properly observe system theme changes.
Common Declaration (commonMain)
@Composable
expect fun ObserveSystemIsDarkTheme(): Boolean
Android Implementation (androidMain)
On Android, we directly use Compose's built-in isSystemInDarkTheme() function:
@Composable
actual fun ObserveSystemIsDarkTheme(): Boolean {
return isSystemInDarkTheme()
}
iOS Implementation (iosMain)
On iOS, Compose's isSystemInDarkTheme() only triggers once and doesn't react to
system theme changes while the app is running. To fix this, we use a custom Swift bridge:
@Composable
actual fun ObserveSystemIsDarkTheme(): Boolean {
val iosTheme by IosThemeObserver.isDarkTheme.collectAsState()
return iosTheme ?: isSystemInDarkTheme()
}
5. iOS Theme Observer Bridge
The IosThemeObserver object acts as a bridge between Swift and Kotlin, allowing
real-time theme updates from iOS system settings:
@OptIn(ExperimentalObjCName::class)
@ObjCName("IosThemeObserver", exact = true)
object IosThemeObserver {
private val _isDarkTheme = MutableStateFlow(null)
val isDarkTheme: StateFlow = _isDarkTheme
fun updateTheme(isDark: Boolean) {
_isDarkTheme.value = isDark
}
}
6. Swift Integration (iOS)
In your iOS app's ContentView.swift, observe the system color scheme and notify the
Kotlin observer whenever it changes:
struct ContentView: View {
@Environment(\.colorScheme) var colorScheme
var body: some View {
ComposeView()
.ignoresSafeArea()
.task {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
IosThemeObserver().updateTheme(isDark: colorScheme == .dark)
}
}
.onChange(of: colorScheme) { newValue in
DispatchQueue.main.async {
IosThemeObserver().updateTheme(isDark: newValue == .dark)
}
}
}
}
This Swift code observes the colorScheme environment value and notifies the Kotlin
observer whenever it changes, ensuring the app's theme stays in sync with system preferences in
real-time.
7. Usage Example
The project includes a default custom color palette structure, but you can create your own color system to match your design requirements.
Define Custom Color Palette
Create an immutable data class for your custom colors and define light/dark variants:
@Immutable
data class CustomColorsPalette(
val backgroundColor: Color = Color.Unspecified,
val gray: Color = Color.Unspecified,
val black: Color = Color.Unspecified
)
val LocalColorsPalette = staticCompositionLocalOf { CustomColorsPalette() }
val OnLightColorsPalette = CustomColorsPalette(
backgroundColor = Color(color = 0xFFFFFFFF),
gray = Color(color = 0xFF000000),
black = Color(color = 0xFFCED5DB),
)
val OnDarkColorsPalette = CustomColorsPalette(
backgroundColor = Color(color = 0xFF202326),
black = Color(color = 0xFFFFFFFF),
gray = Color(color = 0xFF5C5D62)
)
Integrate in Root Screen
In your root composable (typically MainScreen), collect the dark mode flow and
provide the appropriate color palette to your entire app:
@Composable
internal fun MainScreen(darkModeManager: DarkModeManager = koinInject()) {
val isDarkMode by darkModeManager.isSystemDarkMode().collectAsState(initial = false)
val customColors = if (isDarkMode) {
OnDarkColorsPalette
} else {
OnLightColorsPalette
}
CompositionLocalProvider(
LocalColorsPalette provides customColors
) {
Text(text = "MainScreen", color = LocalColorsPalette.current.black)
}
}
Access Colors Anywhere
Throughout your app, access the current theme's colors using
LocalColorsPalette.current. The colors will automatically update when the user
switches themes:
@Composable
fun MyComponent() {
Box(
modifier = Modifier
.fillMaxSize()
.background(LocalColorsPalette.current.backgroundColor)
) {
Text(
text = "Hello, World!",
color = LocalColorsPalette.current.black
)
}
}
This approach ensures consistent theming across your entire app with automatic updates when users change their theme preference.
Multi-Language Support
Implement dynamic language switching with persistent user preferences and system language detection.
Overview
The multi-language system is built on top of DataStore, allowing users to select their preferred language (or use system default) with preferences persisted across app sessions. The system uses a type-safe resource model for managing translations.
1. LanguageManager Interface
The language management is handled through a singleton LanguageManager interface:
interface LanguageManager {
val currentResources: StateFlow<StringResourcesUiModel>
fun getCurrentLanguageFlow(): Flow<AppLanguage>
suspend fun setLanguage(language: AppLanguage)
suspend fun setSystemLanguage()
}
2. Implementation with DataStore
The implementation persists language preferences and manages resource updates:
@Single(binds = [LanguageManager::class])
class LanguageManagerImpl(
@Provided private val dataStore: DataStore
) : LanguageManager {
private val selectedLanguageKey = stringPreferencesKey("selectedLanguage")
private val _currentResources = MutableStateFlow(StringResourcesUiModel())
override val currentResources: StateFlow =
_currentResources.asStateFlow()
override fun getCurrentLanguageFlow(): Flow =
dataStore.data.map { prefs ->
val storedCode = prefs[selectedLanguageKey] ?: AppLanguage.SYSTEM.code
AppLanguage.fromCode(storedCode)
}
override suspend fun setLanguage(language: AppLanguage) {
dataStore.edit { prefs ->
prefs[selectedLanguageKey] = language.code
}
val active = if (language == AppLanguage.SYSTEM) {
AppLanguage.fromCode(getDeviceLanguageCode())
} else {
language
}
updateResource(language = active)
}
override suspend fun setSystemLanguage() {
val prefs = dataStore.data.firstOrNull()
val selectedLanguage = AppLanguage.fromCode(code = prefs?.get(selectedLanguageKey))
if (selectedLanguage == AppLanguage.SYSTEM) {
setLanguage(language = selectedLanguage)
} else {
updateResource(language = selectedLanguage)
}
}
private fun updateResource(language: AppLanguage) {
languageResources[language]?.let { model ->
_currentResources.update { model }
}
}
}
How It Works
- setSystemLanguage(): Called on app startup to load the saved language preference
- setLanguage(): Updates the language preference and loads the appropriate resource model
- getCurrentLanguageFlow(): Returns a Flow of the current language for settings UI
- currentResources: StateFlow that provides the active string resources to the UI
3. Device Language Detection
To detect the device's system language, we use the expect/actual pattern for platform-specific implementations.
Common Declaration (commonMain)
expect fun getDeviceLanguageCode(): String
Android Implementation (androidMain)
actual fun getDeviceLanguageCode(): String {
return Locale.getDefault().language
}
iOS Implementation (iosMain)
actual fun getDeviceLanguageCode(): String {
val preferredLanguages = NSLocale.preferredLanguages.firstOrNull() as? String
return preferredLanguages?.substring(0, 2) ?: "en"
}
4. String Resources
String resources are defined in a type-safe data class. This dummy example shows the basic structure:
data class StringResourcesUiModel(
val title: String = ""
)
Create resource models for each supported language:
internal val resourceEN = StringResourcesUiModel(
title = "Welcome"
)
internal val resourceTR = StringResourcesUiModel(
title = "Hoş Geldiniz"
)
internal val languageResources = mapOf(
AppLanguage.EN to resourceEN,
AppLanguage.TR to resourceTR
)
Adding a new language: Simply add the language to your AppLanguage
enum, create a new StringResourcesUiModel object with translations, and add it to
the languageResources map.
5. Usage Example
In your root screen, collect the current resources and provide them to your app using
CompositionLocalProvider:
val LocalStringResources = staticCompositionLocalOf { StringResourcesUiModel() }
@Composable
internal fun MainScreen(languageManager: LanguageManager = koinInject()) {
val resource by languageManager.currentResources.collectAsState()
LaunchedEffect(key1 = Unit) {
languageManager.setSystemLanguage()
}
CompositionLocalProvider(LocalStringResources provides resource) {
Text(text = LocalStringResources.current.title)
}
}
The LaunchedEffect ensures that the system language is loaded on app startup.
Throughout your app, access strings using LocalStringResources.current, and they
will automatically update when the user changes their language preference.