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.

HomePresentationModule.kt
@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.

KoinInitializer.kt
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.

RestResult.kt
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.

BaseRepository.kt
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.

RestResultExtensions.kt
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.

GithubRepositoryImpl.kt
@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.

SearchGithubUsersUseCase.kt
@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.

FlowExtensions.kt
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.

OnboardingViewModel.kt
@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.

FlowRestResultExtensions.kt
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 Success result is emitted, typically used to update UI state with data.
  • onError: Executes the action when an Error result is emitted, useful for showing error messages.
  • onLoading: Executes the action when a Loading result 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.

detekt.yml
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:

build.gradle.kts
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:

build.gradle.kts
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:

Terminal
./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:

DataStore.kt (commonMain)
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:

DataStore.android.kt (androidMain)
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:

DataStore.ios.kt (iosMain)
@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:

DarkModeManagerImpl.kt
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:

DarkModeManager.kt
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:

DarkModeManagerImpl.kt
@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:

ThemeMode.kt
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)

SystemTheme.kt (commonMain)
@Composable
expect fun ObserveSystemIsDarkTheme(): Boolean

Android Implementation (androidMain)

On Android, we directly use Compose's built-in isSystemInDarkTheme() function:

SystemTheme.android.kt (androidMain)
@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:

SystemTheme.ios.kt (iosMain)
@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:

IosThemeObserver.kt (iosMain)
@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:

ContentView.swift (iosApp)
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:

CustomColorsPalette.kt
@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:

MainScreen.kt
@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:

MyComponent.kt
@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:

LanguageManager.kt
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:

LanguageManagerImpl.kt
@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)

DeviceLanguage.kt (commonMain)
expect fun getDeviceLanguageCode(): String

Android Implementation (androidMain)

DeviceLanguage.android.kt (androidMain)
actual fun getDeviceLanguageCode(): String {
    return Locale.getDefault().language
}

iOS Implementation (iosMain)

DeviceLanguage.ios.kt (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:

StringResourcesUiModel.kt
data class StringResourcesUiModel(
    val title: String = ""
)

Create resource models for each supported language:

LanguageResources.kt
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:

MainScreen.kt
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.

Copied to clipboard!