Статья об использовании мультиплатформенного решения на Compose с минимальным количеством сторонних beta библиотек

Gradle

Добавление зависимостей для каждой платформы делается в build.gradle.kts

androidMain

Android

commonMain

Общие библиотеки

Для всех платформ

iosMain

iOS

sourceSets
    sourceSets {
        androidMain.dependencies {
            implementation(compose.preview)
            implementation(libs.androidx.activity.compose)

            implementation(libs.ktor.client.android)
            implementation(libs.koin.androidx.compose)

            // Koin
            implementation(libs.koin.android)
            implementation(libs.koin.androidx.compose)

        }
        commonMain.dependencies {
            implementation(compose.runtime)
            implementation(compose.foundation)
            implementation(compose.material3)
            implementation(compose.ui)

            implementation(compose.components.resources)
            implementation(compose.components.uiToolingPreview)
            implementation(compose.materialIconsExtended)

            implementation(libs.androidx.lifecycle.viewmodelCompose)
            implementation(libs.androidx.lifecycle.runtimeCompose)

            implementation(libs.androidx.data.store.core)

            implementation(libs.ktor.client.core)
            implementation(libs.ktor.client.content.negotiation)
            implementation(libs.ktor.serialization.kotlinx.json)
            implementation(libs.androidx.room.runtime)

            implementation(libs.sqlite.bundled)
            implementation(libs.coil)
            implementation(libs.coil.compose)
            implementation(libs.coil.network)
            implementation(libs.navigation.compose)

            // Koin
            api(libs.koin.core)
            implementation(libs.koin.compose)
            implementation(libs.koin.composeVM)
            implementation(libs.ktor.logging)
            implementation("org.jetbrains.compose.ui:ui-backhandler:1.8.2")

        }

        iosMain.dependencies {
            implementation(libs.ktor.client.darwin)
        }
    }

Точки входа в приложение на разных платформах

MainActivity - Android
// файл MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        enableEdgeToEdge()
        super.onCreate(savedInstanceState)

        setContent {
            App()
        }
    }
}


// файл MyApplication.kt
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        initKoin {
            androidContext(this@MyApplication)
        }
    }
}
iOSApp - iOS
// файл App.kt

@main
struct iOSApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

// файл ContentView.swift

struct ComposeView: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> UIViewController {
        MainViewControllerKt.MainViewController()
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
    }
}

struct ContentView: View {
    var body: some View {
        ComposeView()
            .ignoresSafeArea()
    }
}

Это минимальный код для запуска общего кода который находится в commonMain.

Очень порадовало что koin с версии 4 поддерживает создание ViewModel в commonMain кроссплатформенном коде без доработок iOs/Android зависимого кода

В общем-то при добавлении экранов, запросов в сеть не требуется каких либо добавлений для каждой платформы

Но для базы данных Room и DataStore требуется добавить один раз Адаптер Expect/Actual

Основная идея сделать мост к файловой системе определенной платформы. Например для Room это сделано через expect/actual так:

expect fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase>
RoomDatabase actual
// iOS
actual fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
    val dbFilePath = documentDirectory() + "/$DB_Name"
    return Room.databaseBuilder<AppDatabase>(
        name = dbFilePath,
    )
}

@OptIn(ExperimentalForeignApi::class)
private fun documentDirectory(): String {
    val documentDirectory = NSFileManager.defaultManager.URLForDirectory(
        directory = NSDocumentDirectory,
        inDomain = NSUserDomainMask,
        appropriateForURL = null,
        create = false,
        error = null,
    )
    return requireNotNull(documentDirectory?.path)
}


// Android
actual fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
    val appContext = KoinPlatform.getKoin().get<Application>()
    val dbFile = appContext.getDatabasePath(DB_Name)
    return Room.databaseBuilder<AppDatabase>(
        context = appContext,
        name = dbFile.absolutePath
    )
}

Модуль koin DI создается в commonMain

databaseModule
val databaseModule = module {

    // database
    single {
        getRoomDatabase(getDatabaseBuilder())
    }

}


fun getRoomDatabase(
    builder: RoomDatabase.Builder<AppDatabase>
): AppDatabase {
    return builder
        .setDriver(BundledSQLiteDriver())
        .setQueryCoroutineContext(DispatchersRepository.io())
        .fallbackToDestructiveMigration(
            dropAllTables = true
        )
        .build()
}

Есть один нюанс при работе с iOS. Dao interface должен возвращать Flow или быть suspend иначе под iOS приложение падает.

@Dao
@Dao
interface PasswordsDao {

    @Query("SELECT * FROM Passwords ORDER BY id")
    fun getAllPasswords(): Flow<List<PasswordsEntity>>

    @Query("SELECT * FROM Passwords WHERE name LIKE '%' || :filter || '%' ORDER BY id")
    fun getFilteredPasswords(filter: String): Flow<List<PasswordsEntity>>

    @Query("SELECT * FROM Passwords WHERE id = :id")
    suspend fun getPasswords(id: String): PasswordsEntity

    @Query("SELECT COUNT(*) as count FROM Passwords")
    suspend fun count(): Int

    @Query("SELECT id FROM Passwords ORDER BY id DESC")
    suspend fun getMaxId(): String

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAllPasswords(passwords: List<PasswordsEntity>)

    @Insert(onConflict = OnConflictStrategy.REPLACE)
   suspend fun insertPassword(password: PasswordsEntity)

    @Update(onConflict = OnConflictStrategy.IGNORE)
    suspend fun updatePassword(password: PasswordsEntity)

    @Delete
    suspend fun deletePassword(password: PasswordsEntity)

}

Аналогичным образом подключается DataStore, который используется для хранения AppPreferences. В проекте таким образом хранится Theme.

AppPreferences
class AppPreferences(
    private val dataStore: DataStore<Preferences>
) {

    private val themeKey = stringPreferencesKey("com.spacex/theme")


    suspend fun getTheme() = dataStore.data.map { preferences ->
        preferences[themeKey] ?: Const.Theme.DARK_MODE.name
    }.first()

    suspend fun changeThemeMode(value: String) = dataStore.edit { preferences ->
        preferences[themeKey] = value
    }

}

Clean Architecture

Clean Architecture
Clean Architecture

Структура папок проекта commonMain выглядит так. Если требуется добавить отдельный module это можно сделать из меню File->New->Module...->Android->Kotlin multiplatform shared module

Связь модуля с приложением Android и iOS
# Android

dependencies {
    ...
    implementation(project(":shared"))
}

# iOS

val xcfName = "sharedKit"

iosX64 {
  binaries.framework {
    baseName = xcfName
  }
}

iosArm64 {
  binaries.framework {
    baseName = xcfName
  }
}

iosSimulatorArm64 {
  binaries.framework {
    baseName = xcfName
  }
}

https://developer.android.com/kotlin/multiplatform/migrate

Settings

Theme
Theme

Theme устанавливаются в Settings. Koin может создать несколько копий viewmodel, а хотелось бы динамически переключать тему для всего приложения по клику на чекбокс в Settings. Поэтому создается SettingsViewModel в App() и пробрасываем ее ниже до самого SettingsScreen. Там же в App() устанавливается тема см. PasswordsTheme->MaterialTheme при старте или по переключению чекбокс в экране Settings

fun App()
fun App() {

    val settingViewModel = koinViewModel<SettingsViewModel>()
    val currentTheme by settingViewModel.viewState.collectAsStateWithLifecycle()
    PasswordsTheme(currentTheme.currentTheme) {
        NavigationApplication(settingViewModel)
    }

}

@Composable
fun PasswordsTheme(
    appTheme: String?,
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colorScheme = when (appTheme) {
        Const.Theme.LIGHT_MODE.name -> {
            LightColorScheme
        }

        Const.Theme.DARK_MODE.name -> {
            DarkColorScheme
        }

        else -> {
            if (darkTheme) {
                DarkColorScheme
            } else {
                LightColorScheme
            }
        }
    }

    MaterialTheme(
        colorScheme = colorScheme,
        content = content,
        typography = CustomTypography()
    )
}

MVI

Концепция MVVM рекомендует создание val uiState: StateFlow во viewModel который доступен во View (Activity, Fragment, Compose fun). Из этого View дергаются публичные методы viewModel. View соответственно коллектит этот uiState

MVI устроен сложнее. Все методы viewModel не публичные, кроме одного handleEvent(Event). Обращение к viewModel идет через так называемые Events. Еще их называют Intents (Намерения) то что мы намереваемся запросить во viewModel. Это замена дергать публичные метод в viewModel. switch/case как раз и дернет эти методы когда встретит/разберет отправленный Event

Так же добавляется Effect который так же как и uiState доступен View. Effect используется для поднятия Тостов и для навигации, подразумевается что он не влияет на uiState. При чем он SharedFlow

Итого получается две "трубы" из viewModel к View (uiState и Effects) и один публичный handleEvent(Event) во viewModel

Можно немного перенести логику в базовый класс BaseViewModel. В нем создать uiState, Event и Effect

abstract class BaseViewModel<Event : ViewEvent, UiState : ViewState, Effect : ViewSideEffect>
    (initUiState: UiState) : ViewModel() 
BaseViewModel
package com.storage.passwords.utils

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.spacex.utils.UiText
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import passwords.composeapp.generated.resources.Res
import passwords.composeapp.generated.resources.unknown_error


interface ViewEvent

interface ViewState

interface ViewSideEffect

abstract class BaseViewModel<Event : ViewEvent, UiState : ViewState, Effect : ViewSideEffect>
    (initUiState: UiState) : ViewModel() {

    val coroutineExceptionHandler = CoroutineExceptionHandler { _, exception: Throwable ->
        viewModelScope.launch {
            onCoroutineException(
                if (exception.message != null)
                    UiText.StaticString(exception.message!!)
                else
                    UiText.StringResource(Res.string.unknown_error)
            )
        }
    }

    abstract fun onCoroutineException(message: UiText)

    val defaultViewModelScope = CoroutineScope(SupervisorJob() + coroutineExceptionHandler)

    abstract fun runInitialEvent()
    abstract fun handleEvents(event: Event)

    private val _viewState: MutableStateFlow<UiState> = MutableStateFlow(initUiState)

    val viewState = _viewState
        .onStart {
            runInitialEvent()
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = _viewState.value
        )

    private val _event: MutableSharedFlow<Event> = MutableSharedFlow()

    private val _effect = MutableSharedFlow<Effect>()
    val effect = _effect.asSharedFlow()


    init {
        subscribeToEvents()
    }

    private fun subscribeToEvents() {
        defaultViewModelScope.launch {
            _event.collect {
                handleEvents(it)
            }
        }
    }

    fun setEvent(event: Event) {
        defaultViewModelScope.launch { _event.emit(event) }
    }

    protected fun setState(reducer: UiState.() -> UiState) {
        val newState = viewState.value.reducer()
        _viewState.value = newState
    }

    protected fun setEffect(builder: () -> Effect) {
        val effectValue = builder()
        defaultViewModelScope.launch { _effect.emit(effectValue) }
    }


}

Взято отсюда android-compose-mvi-navigation. Немного доработал инициализацию uiState и добавил СoroutineExceptionHandler

    val viewState = _viewState
        .onStart {
            runInitialEvent()
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = _viewState.value
        )

Во первых runInitialEvent() перезапустится через 5 сек если была переподписка. Это происходит из-за привязки к жизненному циклу см. ниже Lifecycle.State.STARTED

Например приложение ушло в фон и более чем через 5 сек вернулось. Во вторых после перехода на другой экран, через 5 сек так же произойдет приостановка flow, а при возврате будет перезапущен runInitialEvent(). Это полезно когда например с экрана списка перешли на экран где добавили или изменили запись в базе данных и надо чтобы список обновился при возвращении. init {} во viewModel не сработает! Но опять же надо пробыть на экране добавления/редактирования более 5 сек или изменить значение в WhileSubscribed(t)

По поводу этой задержки не я придумал. В CoroutineLiveData этот DEFAULT_TIMEOUT по умолчанию. Используется для предотвращения перезапуска flow при изменении конфигурации, например повороте экрана

internal class CoroutineLiveData<T>(
    context: CoroutineContext = EmptyCoroutineContext,
    timeoutInMs: Long = DEFAULT_TIMEOUT,
    block: Block<T>
) : MediatorLiveData<T>() {
SharingStarted.WhileSubscribed(5000)

https://blog.p-y.wtf/whilesubscribed5000

Using WhileSubscribed()without any timeout would make sense here, i.e. we want to keep the sharing coroutine running as long as there's a UI consuming it. When that UI goes away, why would we want to wait an additional 5 seconds before we stop sharing?

I find more details in a post from the Android Developers blog:

Tip for Android apps! You can use WhileSubscribed(5000) most of the time to keep the upstream flow active for 5 seconds more after the disappearance of the last collector. That avoids restarting the upstream flow in certain situations such as configuration changes. This tip is especially helpful when upstream flows are expensive to create and when these operators are used in ViewModels.

Surprise Surprise, it's config changes once again, the bane of my Android career...

On Twitter Gabor Varadi pointed out that CoroutineLiveData has the same 5000 ms default timeout.

В статье автор рассматривает эту проблему с перезапуском flow при смене конфигурации и предлагая написать свой SharingStarted

WhileSubscribedOrRetained
object WhileSubscribedOrRetained : SharingStarted {

  private val handler = Handler(Looper.getMainLooper())

  override fun command(subscriptionCount: StateFlow<Int>): Flow<SharingCommand> = subscriptionCount
  .transformLatest { count ->
    if (count > 0) {
      emit(SharingCommand.START)
    } else {
      val posted = CompletableDeferred<Unit>()
      // This code is perfect. Do not change a thing.
      Choreographer.getInstance().postFrameCallback {
        handler.postAtFrontOfQueue {
          handler.post {
            posted.complete(Unit)
          }
        }
      }
      posted.await()
      emit(SharingCommand.STOP)
    }
  }
  .dropWhile { it != SharingCommand.START }
  .distinctUntilChanged()

  override fun toString(): String = "WhileSubscribedOrRetained"
}

Collect

val state = viewModel.viewState.collectAsStateWithLifecycle()

StateFlow compose multiplatform умеет обрабатывать с учетом жизненного цикла из коробки. А SharedFlow нет. Поэтому для Efects применим следующий код для учета жизненного цикла

    val effect = viewModel.effect
        .flowWithLifecycle(
            localLifecycleOwner.lifecycle,
            Lifecycle.State.STARTED
        )

    LaunchedEffect(key1 = localLifecycleOwner.lifecycle) {
        effect.collect {
...

UiText

Еще хотел бы отметить одно удобство. Часто из viewModel требуется передать строковый ресурс или саму строку во View. Решение создать обертку причем применятся может как в coroutine контексте так и нет

UiText
sealed interface UiText {
    data class StaticString(val value: String) : UiText
    class StringResource(
        val resId: org.jetbrains.compose.resources.StringResource,
        vararg val args: Any
    ) : UiText

    @Composable
    fun asString(): String {
        return when (this) {
            is StaticString -> value
            is StringResource -> stringResource(resId, *args)
        }
    }
    suspend fun asStringForSuspend(): String {
        return when (this) {
            is StaticString -> value
            is StringResource -> getString(resId, *args)
        }
    }
}

Server

При создании проекта IntelliJ IDEA предлагает опцию создать сервер для тестирования

embeddedServer
package com.storage.passwords

import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.request.receive
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun main() {
    embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module)
        .start(wait = true)
}

fun Application.module() {
    routing {
        get("/") {
            call.respondText("Ktor: ${Greeting().greet()}")
        }

        get("/passwords") {
            call.response.headers.append(
                HttpHeaders.ContentType,
                ContentType.Application.Json.toString()
            )
            call.respondText(
                """
                    [ 
                    { "id":"1", "name":"password1", "password":"ADAD%ADAD", "note":"I note it"},
                    { "id":"2", "name":"password2", "password":"1ADAD%ADAD", "note":"I note it eee"},
                    { "id":"3", "name":"password3", "password":"2ADAD%ADAD", "note":"I note it fff"}
                    ]
                    """.trimMargin()
            )
        }

        get("/submit") {
            val receivedData = call.receive<String>() // Assuming plain text data

            // Process the received data
            println("Received POST data: $receivedData")

            // Send a response back to the client
            call.respondText("Data received successfully!")
        }

        post("/submit-password") {
            // Receive the data from the request body
            val receivedData = call.receive<String>() // Assuming plain text data

            // Process the received data
            println("Received POST data: $receivedData")

            // Send a response back to the client
            call.respondText("Data received successfully!")
        }

    }
}
feed
feed

Достаточно добавить роутов get и post и можно полноценно тестировать

get("/submit") {
    get("/passwords") {
        call.response.headers.append(
            HttpHeaders.ContentType,
            ContentType.Application.Json.toString()
        )
        call.respondText("....")
  ...

post("/submit-password") {
  ...

Android Эмулятор не видит адрес http://0.0.0.0:8080/ поэтому можно как вариант запустить ifconfig и посмотреть адрес вида 192.168.1.100

Прописать в ConfigRepository. BuildKonfig.Is_Debug_Server используется для управления переключением при сборке проекта

ConfigRepository
class ConfigRepository {

    private val isDebugBuild = BuildKonfig.Is_Debug_Server

    fun getBaseUrl(): String {
        return if (isDebugBuild)
            BASE_URL_DEBUG
        else
            BASE_URL_RELEASE
    }

    companion object Companion {
        private const val BASE_URL_RELEASE = "https://0.0.0.0:8080"
        private const val BASE_URL_DEBUG = "http://192.168.1.215:8080"
    }

}

Android server debug

Для отладки на тестовом сервере требуется прописать в Manifest usesCleartextTraffic, потому что протокол запросов http. Убрать в релизной версии

android:usesCleartextTraffic="true"  

iOS server debug

Под iOS необходимы дополнительные настройки проекта для тестирования с сервером. Не забудьте убрать в продакшн NSExceptionAllowsInsecureHTTPLoads

iOS plist file for Netty Server
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
    <dict>
        <key>CADisableMinimumFrameDurationOnPhone</key>
        <true/>
        <key>NSAppTransportSecurity</key>
        <dict>
            <key>NSExceptionDomains</key>
            <dict>
                <key>localhost</key>
                <dict>
                    <key>NSExceptionAllowsInsecureHTTPLoads</key>
                    <true/>
                </dict>
            </dict>
        </dict>
    </dict>
</plist>

Для iOs так же потребуется запустить xcode и выбрать team

In Xcode, under the "Signing & Capabilities" tab of an app target, a specific development team must be selected

Глаз

Если реагировать на единичное нажатие и запустить например таймер, то проблем нет. Хотелось сделать показ именно на время удержания пальца на иконке. Поэтому проверяем it.pressed

Но когда отпускаешь палец, то проваливаешься в Detail, как будто кликнул на элементе списка. Требуется небольшая задержка, чтобы этот механизм не сработал. Пришлось сделать хак при просмотре поля по нажатию на "глаз"

    .clickable {
       if (timeLeft <= 0) {        
         onClick.invoke(passwordItem)    // переход на Detail
       }},

И реализация небольшой задержки. Возможно как-то проще?

delay(100)
    var showPassword by remember { mutableStateOf(false) }


    val navAllow = derivedStateOf { !showPassword }

    LaunchedEffect(key1 = navAllow.value) {
        while (timeLeft > 0) {
            delay(100)
            timeLeft--
        }
    }

  ...
     Text(
        modifier = Modifier
            .padding(top = 4.dp, start = 24.dp),
        text = if (showPassword) passwordItem.password else "*********"
    )

  
  ...
    Icon(
        modifier = Modifier.pointerInput(Unit) {
            awaitEachGesture {
                val down = awaitFirstDown()
                // Handle the down event
                showPassword = true

                do {
                    val event = awaitPointerEvent()
                } while (event.changes.any { it.pressed })

                showPassword = false
                timeLeft = 1
            }
        },
        imageVector = if (showPassword) Icons.Filled.Visibility else Icons.Outlined.Visibility,
        contentDescription = ""
    )
...
Глаз
Глаз

Меню

Меню реализовано через бургер меню. BurgerMenu это обертка над ModalNavigationDrawer

BurgerMenu
    BurgerMenu(
        drawerState = drawerState,
        onAddItem = {
            navController.currentBackStackEntry?.savedStateHandle?.apply {
                val jsonFalconInfo = Json.encodeToString("-1")
                set(PASSWORD_ID_PARAM, jsonFalconInfo)
            }
            navController.navigate(Screen.Detail.route)
        },
        onAboutItem = {
            navController.navigate(Screen.About.route)
        },
        onSettingsItem = {
            navController.navigate(Screen.Settings.route)
        }
    ) {


        NavHost(
          ...


@Composable
fun BurgerMenu(
    onAboutItem: () -> Unit,
    onAddItem: () -> Unit,
    onSettingsItem: () -> Unit,
    drawerState: DrawerState,
    content: @Composable () -> Unit
) {

    val scope = rememberCoroutineScope()

    ModalNavigationDrawer(
        drawerState = drawerState,
        drawerContent = {
            ModalDrawerSheet {
                Text("Menu", modifier = Modifier.padding(16.dp))
                HorizontalDivider()
                NavigationDrawerItem(
                    label = {
                        Text(text = stringResource(Res.string.about))
                    },
                    selected = false,
                    onClick = {
                        onAboutItem.invoke()
                        scope.launch { drawerState.close() }
                    }
                )
                NavigationDrawerItem(
                    label = {
                        Text(text = stringResource(Res.string.title_settings))
                    },
                    selected = false,
                    onClick = {
                        onSettingsItem.invoke()
                        scope.launch { drawerState.close() }
                    }
                )
                NavigationDrawerItem(
                    label = {
                        Text(text = stringResource(Res.string.add_password))
                    },
                    selected = false,
                    onClick = {
                        onAddItem.invoke()
                        scope.launch { drawerState.close() }
                    }
                )
            }
        }
    ) {
            // Main screen content
            content()
    }
}

          

Navigation

Навигация через NavHost org.jetbrains.androidx.navigation:navigation-compose navigationCompose = "2.9.0-beta05". На момент написания статьи появился navigationCompose = "2.9.0-rc01" в которой исправлено несколько багов

routes
sealed class Screen(val route: String) {

    object Home : Screen("home")
    object Detail : Screen("detail")
    object About : Screen("about")
    object Settings : Screen("settings")
}

...


        NavHost(
            navController = navController,
            startDestination = Screen.Home.route
        ) {
            composable(Screen.Home.route) {
                PasswordsScreen(
                    drawerState = drawerState,
                    currentItem = { password_id ->
                        navController.currentBackStackEntry?.savedStateHandle?.apply {
                            val jsonFalconInfo = Json.encodeToString(password_id)
                            set(PASSWORD_ID_PARAM, jsonFalconInfo)
                        }
                        navController.navigate(Screen.Detail.route)
                    }

                )
            }
            composable(Screen.About.route) {
                AboutScreen(
                    onStartClick = {
                        navController.popBackStack()
                    }
                )
            }
            composable(
                route = Screen.Detail.route,
            ) {
                navController.previousBackStackEntry?.savedStateHandle?.get<String>(PASSWORD_ID_PARAM)
                    ?.let { jsonId ->
                        val password_id = Json.decodeFromString<String>(jsonId)
                        DetailScreen(
                            password_id = password_id,
                            onBackHandler = {
                                navController.popBackStack()
                            }
                        )
                    }
            }
            composable(Screen.Settings.route) {
                SettingsScreen(
                    viewModel = settingViewModel,
                    {
                        navController.popBackStack()
                    }
                )
            }
            

P.S.

OutlinedTextField - не работает в iOs. Приложение падает при попытке редактирования поля. Есть решение по замене на нативное поле или ждать исправления в следующих версиях Compose

Еще не провер но пишут что решили

https://youtrack.jetbrains.com/projects/CMP/issues/CMP-8764/iOS-Application-crashed-when-Touch-the-OutlinedTextField

Can confirm I no longer replicate this issue with

androidx-lifecycle = "2.9.3"
androidx-navigation = "2.9.0-rc01"

Были проблемы с навигацией назад с использованием Gesture, но вроде после обновления библиотеки навигации можно убрать хак BackHandler

BackHandler
    BackHandler(enabled = true) {
        println("BackHandler")
        viewModel.setEvent(DetailEvent.NavigationBack)
    }

Проект в котором использован код из статьи доступен на Github

https://github.com/app-z/Passwords

Ссылки по теме