From ef80db5c2327452002939d0ed09d145efeb11e4f Mon Sep 17 00:00:00 2001 From: utamori Date: Mon, 19 Jan 2026 22:03:18 +0900 Subject: [PATCH 1/2] Migrate iOS17 refactoring and Android Navigation 3 changes - Android: Migrate from composeApp to androidApp with Navigation 3 - iOS: Migrate to @Observable macro, add IosViewModelStoreOwner and ViewModelResolver - Shared: Update ViewModels, add KoinDependencies - Build: Update Gradle 9.1.0, AGP 9.0.0, Kotlin 2.3.0, dependencies --- README.md | 2 +- androidApp/build.gradle.kts | 57 ++++++++++++ .../src/main}/AndroidManifest.xml | 0 .../main/kotlin/com/jetbrains/kmpapp/App.kt | 53 +++++++++++ .../com/jetbrains/kmpapp/MainActivity.kt | 0 .../kotlin/com/jetbrains/kmpapp/MuseumApp.kt | 0 .../jetbrains/kmpapp/screens/DetailScreen.kt | 0 .../kmpapp/screens/EmptyScreenContent.kt | 0 .../jetbrains/kmpapp/screens/ListScreen.kt | 0 .../drawable-v24/ic_launcher_foreground.xml | 0 .../res/drawable/ic_launcher_background.xml | 0 .../res/mipmap-anydpi-v26/ic_launcher.xml | 0 .../mipmap-anydpi-v26/ic_launcher_round.xml | 0 .../src/main}/res/mipmap-hdpi/ic_launcher.png | Bin .../res/mipmap-hdpi/ic_launcher_round.png | Bin .../src/main}/res/mipmap-mdpi/ic_launcher.png | Bin .../res/mipmap-mdpi/ic_launcher_round.png | Bin .../main}/res/mipmap-xhdpi/ic_launcher.png | Bin .../res/mipmap-xhdpi/ic_launcher_round.png | Bin .../main}/res/mipmap-xxhdpi/ic_launcher.png | Bin .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin .../main}/res/mipmap-xxxhdpi/ic_launcher.png | Bin .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin .../src/main}/res/values/strings.xml | 0 build.gradle.kts | 1 + composeApp/build.gradle.kts | 72 -------------- .../kotlin/com/jetbrains/kmpapp/App.kt | 47 ---------- gradle.properties | 2 - gradle/libs.versions.toml | 39 ++++---- gradle/wrapper/gradle-wrapper.properties | 2 +- iosApp/iosApp.xcodeproj/project.pbxproj | 45 ++------- .../xcshareddata/swiftpm/Package.resolved | 11 +-- iosApp/iosApp/DetailView.swift | 60 ++++++++---- iosApp/iosApp/IosViewModelStoreOwner.swift | 31 ++++++ iosApp/iosApp/ListView.swift | 88 +++++++++++------- settings.gradle.kts | 2 +- shared/build.gradle.kts | 28 ++---- .../kmpapp/screens/DetailViewModel.kt | 6 +- .../jetbrains/kmpapp/screens/ListViewModel.kt | 5 +- .../com/jetbrains/kmpapp/KoinDependencies.kt | 17 ++++ .../com/jetbrains/kmpapp/ViewModelResolver.kt | 31 ++++++ 41 files changed, 339 insertions(+), 260 deletions(-) create mode 100644 androidApp/build.gradle.kts rename {composeApp/src/androidMain => androidApp/src/main}/AndroidManifest.xml (100%) create mode 100644 androidApp/src/main/kotlin/com/jetbrains/kmpapp/App.kt rename {composeApp/src/androidMain => androidApp/src/main}/kotlin/com/jetbrains/kmpapp/MainActivity.kt (100%) rename {composeApp/src/androidMain => androidApp/src/main}/kotlin/com/jetbrains/kmpapp/MuseumApp.kt (100%) rename {composeApp/src/androidMain => androidApp/src/main}/kotlin/com/jetbrains/kmpapp/screens/DetailScreen.kt (100%) rename {composeApp/src/androidMain => androidApp/src/main}/kotlin/com/jetbrains/kmpapp/screens/EmptyScreenContent.kt (100%) rename {composeApp/src/androidMain => androidApp/src/main}/kotlin/com/jetbrains/kmpapp/screens/ListScreen.kt (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/drawable-v24/ic_launcher_foreground.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/drawable/ic_launcher_background.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-anydpi-v26/ic_launcher.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-anydpi-v26/ic_launcher_round.xml (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-hdpi/ic_launcher.png (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-hdpi/ic_launcher_round.png (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-mdpi/ic_launcher.png (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-mdpi/ic_launcher_round.png (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-xhdpi/ic_launcher.png (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-xhdpi/ic_launcher_round.png (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-xxhdpi/ic_launcher.png (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-xxhdpi/ic_launcher_round.png (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-xxxhdpi/ic_launcher.png (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/mipmap-xxxhdpi/ic_launcher_round.png (100%) rename {composeApp/src/androidMain => androidApp/src/main}/res/values/strings.xml (100%) delete mode 100644 composeApp/build.gradle.kts delete mode 100644 composeApp/src/androidMain/kotlin/com/jetbrains/kmpapp/App.kt create mode 100644 iosApp/iosApp/IosViewModelStoreOwner.swift create mode 100644 shared/src/iosMain/kotlin/com/jetbrains/kmpapp/ViewModelResolver.kt diff --git a/README.md b/README.md index 41f26a7..8093f33 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The app uses the following multiplatform dependencies in its implementation: - [Ktor](https://ktor.io/) for networking - [kotlinx.serialization](https://github.com/Kotlin/kotlinx.serialization) for JSON handling - [Koin](https://github.com/InsertKoinIO/koin) for dependency injection -- [KMP-ObservableViewModel](https://github.com/rickclephas/KMP-ObservableViewModel) for shared ViewModel implementations in common code +- [AndroidX Lifecycle ViewModel](https://developer.android.com/jetpack/androidx/releases/lifecycle) for shared ViewModel implementations - [KMP-NativeCoroutines](https://github.com/rickclephas/KMP-NativeCoroutines) > These are just some of the possible libraries to use for these tasks with Kotlin Multiplatform, and their usage here isn't a strong recommendation for these specific libraries over the available alternatives. You can find a wide variety of curated multiplatform libraries in the [kmp-awesome](https://github.com/terrakok/kmp-awesome) repository. diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts new file mode 100644 index 0000000..b23cec2 --- /dev/null +++ b/androidApp/build.gradle.kts @@ -0,0 +1,57 @@ +plugins { + alias(libs.plugins.androidApplication) + alias(libs.plugins.composeCompiler) + alias(libs.plugins.kotlinxSerialization) +} + +android { + namespace = "com.jetbrains.kmpapp" + compileSdk = 36 + + defaultConfig { + applicationId = "com.jetbrains.kmpapp" + minSdk = 24 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons) + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.viewmodel.compose) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) + implementation(libs.koin.androidx.compose) + implementation(libs.coil.compose) + implementation(libs.coil.network.ktor) + implementation(projects.shared) + implementation(libs.kotlinx.serialization.core) + debugImplementation(libs.androidx.compose.ui.tooling) +} diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml similarity index 100% rename from composeApp/src/androidMain/AndroidManifest.xml rename to androidApp/src/main/AndroidManifest.xml diff --git a/androidApp/src/main/kotlin/com/jetbrains/kmpapp/App.kt b/androidApp/src/main/kotlin/com/jetbrains/kmpapp/App.kt new file mode 100644 index 0000000..95eb745 --- /dev/null +++ b/androidApp/src/main/kotlin/com/jetbrains/kmpapp/App.kt @@ -0,0 +1,53 @@ +package com.jetbrains.kmpapp + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.ui.NavDisplay +import com.jetbrains.kmpapp.screens.DetailScreen +import com.jetbrains.kmpapp.screens.ListScreen +import kotlinx.serialization.Serializable + +@Serializable +data object ListDestination + +@Serializable +data class DetailDestination(val objectId: Int) + +@Composable +fun App() { + MaterialTheme( + colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + ) { + Surface { + val backStack = remember { mutableStateListOf(ListDestination) } + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryProvider = { key -> + when (key) { + is ListDestination -> NavEntry(key) { + ListScreen(navigateToDetails = { objectId -> + backStack.add(DetailDestination(objectId)) + }) + } + is DetailDestination -> NavEntry(key) { + DetailScreen( + objectId = key.objectId, + navigateBack = { backStack.removeLastOrNull() } + ) + } + else -> NavEntry(key) { } + } + } + ) + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/jetbrains/kmpapp/MainActivity.kt b/androidApp/src/main/kotlin/com/jetbrains/kmpapp/MainActivity.kt similarity index 100% rename from composeApp/src/androidMain/kotlin/com/jetbrains/kmpapp/MainActivity.kt rename to androidApp/src/main/kotlin/com/jetbrains/kmpapp/MainActivity.kt diff --git a/composeApp/src/androidMain/kotlin/com/jetbrains/kmpapp/MuseumApp.kt b/androidApp/src/main/kotlin/com/jetbrains/kmpapp/MuseumApp.kt similarity index 100% rename from composeApp/src/androidMain/kotlin/com/jetbrains/kmpapp/MuseumApp.kt rename to androidApp/src/main/kotlin/com/jetbrains/kmpapp/MuseumApp.kt diff --git a/composeApp/src/androidMain/kotlin/com/jetbrains/kmpapp/screens/DetailScreen.kt b/androidApp/src/main/kotlin/com/jetbrains/kmpapp/screens/DetailScreen.kt similarity index 100% rename from composeApp/src/androidMain/kotlin/com/jetbrains/kmpapp/screens/DetailScreen.kt rename to androidApp/src/main/kotlin/com/jetbrains/kmpapp/screens/DetailScreen.kt diff --git a/composeApp/src/androidMain/kotlin/com/jetbrains/kmpapp/screens/EmptyScreenContent.kt b/androidApp/src/main/kotlin/com/jetbrains/kmpapp/screens/EmptyScreenContent.kt similarity index 100% rename from composeApp/src/androidMain/kotlin/com/jetbrains/kmpapp/screens/EmptyScreenContent.kt rename to androidApp/src/main/kotlin/com/jetbrains/kmpapp/screens/EmptyScreenContent.kt diff --git a/composeApp/src/androidMain/kotlin/com/jetbrains/kmpapp/screens/ListScreen.kt b/androidApp/src/main/kotlin/com/jetbrains/kmpapp/screens/ListScreen.kt similarity index 100% rename from composeApp/src/androidMain/kotlin/com/jetbrains/kmpapp/screens/ListScreen.kt rename to androidApp/src/main/kotlin/com/jetbrains/kmpapp/screens/ListScreen.kt diff --git a/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/androidApp/src/main/res/drawable-v24/ic_launcher_foreground.xml similarity index 100% rename from composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml rename to androidApp/src/main/res/drawable-v24/ic_launcher_foreground.xml diff --git a/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml b/androidApp/src/main/res/drawable/ic_launcher_background.xml similarity index 100% rename from composeApp/src/androidMain/res/drawable/ic_launcher_background.xml rename to androidApp/src/main/res/drawable/ic_launcher_background.xml diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml rename to androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 100% rename from composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png rename to androidApp/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png rename to androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png rename to androidApp/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png rename to androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png rename to androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png rename to androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png rename to androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png rename to androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png rename to androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png rename to androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/composeApp/src/androidMain/res/values/strings.xml b/androidApp/src/main/res/values/strings.xml similarity index 100% rename from composeApp/src/androidMain/res/values/strings.xml rename to androidApp/src/main/res/values/strings.xml diff --git a/build.gradle.kts b/build.gradle.kts index 64c0f43..3a034c1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.androidApplication) apply false alias(libs.plugins.androidLibrary) apply false + alias(libs.plugins.androidKmpLibrary) apply false alias(libs.plugins.composeCompiler) apply false alias(libs.plugins.composeMultiplatform) apply false alias(libs.plugins.kotlinMultiplatform) apply false diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts deleted file mode 100644 index ed2a575..0000000 --- a/composeApp/build.gradle.kts +++ /dev/null @@ -1,72 +0,0 @@ -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.androidApplication) - alias(libs.plugins.composeCompiler) - alias(libs.plugins.composeMultiplatform) - alias(libs.plugins.kotlinxSerialization) -} - -kotlin { - androidTarget { - @OptIn(ExperimentalKotlinGradlePluginApi::class) - compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) - } - } - - sourceSets { - androidMain.dependencies { - implementation(libs.androidx.compose.ui.tooling.preview) - implementation(libs.androidx.activity.compose) - implementation(libs.androidx.viewmodel.compose) - implementation(libs.androidx.navigation.compose) - implementation(libs.koin.androidx.compose) - implementation(libs.coil.compose) - implementation(libs.coil.network.ktor) - } - commonMain.dependencies { - implementation(compose.runtime) - implementation(compose.foundation) - implementation(compose.material3) - implementation(compose.ui) - implementation(compose.components.resources) - implementation(compose.components.uiToolingPreview) - implementation(projects.shared) - implementation(libs.kotlinx.serialization.core) - } - } -} - -android { - namespace = "com.jetbrains.kmpapp" - compileSdk = 35 - - defaultConfig { - applicationId = "com.jetbrains.kmpapp" - minSdk = 24 - targetSdk = 35 - versionCode = 1 - versionName = "1.0" - } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } - buildTypes { - getByName("release") { - isMinifyEnabled = false - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } -} - -dependencies { - debugImplementation(libs.androidx.compose.ui.tooling) -} diff --git a/composeApp/src/androidMain/kotlin/com/jetbrains/kmpapp/App.kt b/composeApp/src/androidMain/kotlin/com/jetbrains/kmpapp/App.kt deleted file mode 100644 index cec6702..0000000 --- a/composeApp/src/androidMain/kotlin/com/jetbrains/kmpapp/App.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.jetbrains.kmpapp - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import androidx.navigation.toRoute -import com.jetbrains.kmpapp.screens.DetailScreen -import com.jetbrains.kmpapp.screens.ListScreen -import kotlinx.serialization.Serializable - -@Serializable -object ListDestination - -@Serializable -data class DetailDestination(val objectId: Int) - -@Composable -fun App() { - MaterialTheme( - colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() - ) { - Surface { - val navController = rememberNavController() - NavHost(navController = navController, startDestination = ListDestination) { - composable { - ListScreen(navigateToDetails = { objectId -> - navController.navigate(DetailDestination(objectId)) - }) - } - composable { backStackEntry -> - DetailScreen( - objectId = backStackEntry.toRoute().objectId, - navigateBack = { - navController.popBackStack() - } - ) - } - } - } - } -} diff --git a/gradle.properties b/gradle.properties index 9d33793..3ecdbda 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,5 +8,3 @@ org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 android.nonTransitiveRClass=true android.useAndroidX=true -# Workaround for https://youtrack.jetbrains.com/issue/KT-74278/, should be fixed in Kotlin 2.2.0 -kotlin.native.toolchain.enabled=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fea4c65..407df89 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,28 +1,34 @@ [versions] -agp = "8.9.3" -androidx-activityCompose = "1.10.1" -androidx-navigationCompose = "2.9.0" -androidx-ui-tooling = "1.8.3" -androidx-viewmodelCompose = "2.9.1" -coil = "3.2.0" -compose-multiplatform = "1.8.2" -kmpObservableViewmodel = "1.0.0-BETA-12" -kmpNativeCoroutines = "1.0.0-ALPHA-45" -koin = "4.1.0" -kotlin = "2.2.0" -kotlinx-serialization = "1.8.1" -ksp = "2.2.0-2.0.2" -ktor = "3.1.3" +agp = "9.0.0" +androidx-activityCompose = "1.12.2" +androidx-navigation3 = "1.0.0" +androidx-ui-tooling = "1.10.1" +androidx-viewmodelCompose = "2.10.0" +coil = "3.3.0" +compose-multiplatform = "1.10.0" +kmpNativeCoroutines = "1.0.0" +koin = "4.1.1" +kotlin = "2.3.0" +kotlinx-serialization = "1.9.0" +ksp = "2.3.3" +ktor = "3.3.3" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version = "2026.01.00" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "androidx-ui-tooling" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "androidx-ui-tooling" } -androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigationCompose" } +androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "androidx-navigation3" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "androidx-navigation3" } androidx-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-viewmodelCompose" } coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } coil-network-ktor = { group = "io.coil-kt.coil3", name = "coil-network-ktor3", version.ref = "coil" } -kmp-observable-viewmodel = { module = "com.rickclephas.kmp:kmp-observableviewmodel-core", version.ref = "kmpObservableViewmodel" } +androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-viewmodelCompose" } koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } @@ -35,6 +41,7 @@ ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" } +androidKmpLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } kmpNativeCoroutines = { id = "com.rickclephas.kmp.nativecoroutines", version.ref = "kmpNativeCoroutines" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37f853b..2e11132 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index edf61e7..66134d5 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -11,12 +11,10 @@ 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; }; 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; }; 7555FF83242A565900829871 /* ListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ListView.swift */; }; - EB049AFF2C1AEB680032A688 /* KMPObservableViewModelCore in Frameworks */ = {isa = PBXBuildFile; productRef = EB049AFE2C1AEB680032A688 /* KMPObservableViewModelCore */; }; - EB049B012C1AEB680032A688 /* KMPObservableViewModelSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = EB049B002C1AEB680032A688 /* KMPObservableViewModelSwiftUI */; }; EB1C5CC72AE06906002852BE /* DetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB1C5CC62AE06906002852BE /* DetailView.swift */; }; EB50C4902BE0EB69005DE781 /* KMPNativeCoroutinesAsync in Frameworks */ = {isa = PBXBuildFile; productRef = EB50C48F2BE0EB69005DE781 /* KMPNativeCoroutinesAsync */; }; EB50C4922BE0EB69005DE781 /* KMPNativeCoroutinesCore in Frameworks */ = {isa = PBXBuildFile; productRef = EB50C4912BE0EB69005DE781 /* KMPNativeCoroutinesCore */; }; - EB50C4942BE0F0D8005DE781 /* KMPObservableViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB50C4932BE0F0D8005DE781 /* KMPObservableViewModel.swift */; }; + EB50C4942BE0F0D8005DE781 /* IosViewModelStoreOwner.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB50C4932BE0F0D8005DE781 /* IosViewModelStoreOwner.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -28,7 +26,7 @@ 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; AB3632DC29227652001CCB65 /* Config.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; EB1C5CC62AE06906002852BE /* DetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailView.swift; sourceTree = ""; }; - EB50C4932BE0F0D8005DE781 /* KMPObservableViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KMPObservableViewModel.swift; sourceTree = ""; }; + EB50C4932BE0F0D8005DE781 /* IosViewModelStoreOwner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IosViewModelStoreOwner.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -36,8 +34,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - EB049AFF2C1AEB680032A688 /* KMPObservableViewModelCore in Frameworks */, - EB049B012C1AEB680032A688 /* KMPObservableViewModelSwiftUI in Frameworks */, EB50C4922BE0EB69005DE781 /* KMPNativeCoroutinesCore in Frameworks */, EB50C4902BE0EB69005DE781 /* KMPNativeCoroutinesAsync in Frameworks */, ); @@ -88,7 +84,7 @@ 2152FB032600AC8F00CF470E /* iOSApp.swift */, 058557D7273AAEEB004C7B11 /* Preview Content */, EB1C5CC62AE06906002852BE /* DetailView.swift */, - EB50C4932BE0F0D8005DE781 /* KMPObservableViewModel.swift */, + EB50C4932BE0F0D8005DE781 /* IosViewModelStoreOwner.swift */, ); path = iosApp; sourceTree = ""; @@ -121,8 +117,6 @@ packageProductDependencies = ( EB50C48F2BE0EB69005DE781 /* KMPNativeCoroutinesAsync */, EB50C4912BE0EB69005DE781 /* KMPNativeCoroutinesCore */, - EB049AFE2C1AEB680032A688 /* KMPObservableViewModelCore */, - EB049B002C1AEB680032A688 /* KMPObservableViewModelSwiftUI */, ); productName = iosApp; productReference = 7555FF7B242A565900829871 /* KMP App.app */; @@ -155,7 +149,6 @@ mainGroup = 7555FF72242A565900829871; packageReferences = ( EB50C48E2BE0EB69005DE781 /* XCRemoteSwiftPackageReference "KMP-NativeCoroutines" */, - EB049AFD2C1AEB680032A688 /* XCRemoteSwiftPackageReference "KMP-ObservableViewModel" */, ); productRefGroup = 7555FF7C242A565900829871 /* Products */; projectDirPath = ""; @@ -206,7 +199,7 @@ files = ( EB1C5CC72AE06906002852BE /* DetailView.swift in Sources */, 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */, - EB50C4942BE0F0D8005DE781 /* KMPObservableViewModel.swift in Sources */, + EB50C4942BE0F0D8005DE781 /* IosViewModelStoreOwner.swift in Sources */, 7555FF83242A565900829871 /* ListView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -267,7 +260,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.3; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -324,7 +317,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.3; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -348,7 +341,7 @@ "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", ); INFOPLIST_FILE = iosApp/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -356,7 +349,7 @@ PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}"; PRODUCT_NAME = "${APP_NAME}"; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -375,7 +368,7 @@ "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", ); INFOPLIST_FILE = iosApp/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -383,7 +376,7 @@ PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}"; PRODUCT_NAME = "${APP_NAME}"; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -412,14 +405,6 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - EB049AFD2C1AEB680032A688 /* XCRemoteSwiftPackageReference "KMP-ObservableViewModel" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/rickclephas/KMP-ObservableViewModel"; - requirement = { - kind = exactVersion; - version = "1.0.0-BETA-12"; - }; - }; EB50C48E2BE0EB69005DE781 /* XCRemoteSwiftPackageReference "KMP-NativeCoroutines" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/rickclephas/KMP-NativeCoroutines.git"; @@ -431,16 +416,6 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - EB049AFE2C1AEB680032A688 /* KMPObservableViewModelCore */ = { - isa = XCSwiftPackageProductDependency; - package = EB049AFD2C1AEB680032A688 /* XCRemoteSwiftPackageReference "KMP-ObservableViewModel" */; - productName = KMPObservableViewModelCore; - }; - EB049B002C1AEB680032A688 /* KMPObservableViewModelSwiftUI */ = { - isa = XCSwiftPackageProductDependency; - package = EB049AFD2C1AEB680032A688 /* XCRemoteSwiftPackageReference "KMP-ObservableViewModel" */; - productName = KMPObservableViewModelSwiftUI; - }; EB50C48F2BE0EB69005DE781 /* KMPNativeCoroutinesAsync */ = { isa = XCSwiftPackageProductDependency; package = EB50C48E2BE0EB69005DE781 /* XCRemoteSwiftPackageReference "KMP-NativeCoroutines" */; diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b3a0603..63d1081 100644 --- a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "db79fcc1e89ddda40b63c6b4784113d9a3818a7e51c1cbcb2a074b98d190aa0f", + "originHash" : "609133b310e9c4579a1d75e9696445b51bd516493282d44ac82bc290103e37c2", "pins" : [ { "identity" : "kmp-nativecoroutines", @@ -9,15 +9,6 @@ "revision" : "85c30240bf393bc50c1f10256300afbfe230ba22", "version" : "1.0.0-ALPHA-45-spm-async" } - }, - { - "identity" : "kmp-observableviewmodel", - "kind" : "remoteSourceControl", - "location" : "https://github.com/rickclephas/KMP-ObservableViewModel", - "state" : { - "revision" : "7f94fb6a63c25ee77f464c6eb7aeb38d9ed587d5", - "version" : "1.0.0-BETA-12" - } } ], "version" : 3 diff --git a/iosApp/iosApp/DetailView.swift b/iosApp/iosApp/DetailView.swift index 2e82b36..d286798 100644 --- a/iosApp/iosApp/DetailView.swift +++ b/iosApp/iosApp/DetailView.swift @@ -2,24 +2,41 @@ import Foundation import SwiftUI import Shared import KMPNativeCoroutinesAsync -import KMPObservableViewModelSwiftUI struct DetailView: View { - @StateViewModel - var viewModel = DetailViewModel( - museumRepository: KoinDependencies().museumRepository - ) + @StateObject private var viewModelStoreOwner = IosViewModelStoreOwner() + + @State private var museumObject: MuseumObject? let objectId: Int32 + private var viewModel: DetailViewModel { + viewModelStoreOwner.viewModel(factory: KoinDependencies().detailViewModelFactory) + } + var body: some View { - VStack { - if let obj = viewModel.museumObject { + ZStack { + if let obj = museumObject { ObjectDetails(obj: obj) + } else { + EmptyScreenContent() } } - .onAppear { + .task { viewModel.setId(objectId: objectId) + await observeMuseumObject() + } + } + + @MainActor + private func observeMuseumObject() async { + do { + let stream = asyncSequence(for: viewModel.museumObjectFlow) + for try await newObject in stream { + self.museumObject = newObject + } + } catch { + print("Failed observing museum object: \(error)") } } } @@ -29,26 +46,27 @@ struct ObjectDetails: View { var body: some View { ScrollView { - - VStack { + VStack(alignment: .leading, spacing: 0) { AsyncImage(url: URL(string: obj.primaryImageSmall)) { phase in switch phase { - case .empty: - ProgressView() case .success(let image): image .resizable() - .scaledToFill() - .clipped() + .scaledToFit() default: - EmptyView() + Color(white: 0.9) + .frame(height: 300) } } + .frame(maxWidth: .infinity) + .background(Color(white: 0.9)) - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 0) { Text(obj.title) .font(.title) + Spacer().frame(height: 6) + LabeledInfo(label: "Artist", data: obj.artistDisplayName) LabeledInfo(label: "Date", data: obj.objectDate) LabeledInfo(label: "Dimensions", data: obj.dimensions) @@ -57,7 +75,7 @@ struct ObjectDetails: View { LabeledInfo(label: "Repository", data: obj.repository) LabeledInfo(label: "Credits", data: obj.creditLine) } - .padding(16) + .padding(12) } } } @@ -68,7 +86,11 @@ struct LabeledInfo: View { var data: String var body: some View { - Spacer() - Text("**\(label):** \(data)") + VStack(alignment: .leading) { + Spacer().frame(height: 6) + + Text("**\(label):** \(data)") + } + .padding(.vertical, 4) } } diff --git a/iosApp/iosApp/IosViewModelStoreOwner.swift b/iosApp/iosApp/IosViewModelStoreOwner.swift new file mode 100644 index 0000000..8a921bd --- /dev/null +++ b/iosApp/iosApp/IosViewModelStoreOwner.swift @@ -0,0 +1,31 @@ +import Foundation +import SwiftUI +import Shared + +class IosViewModelStoreOwner: ObservableObject, ViewModelStoreOwner { + let viewModelStore = ViewModelStore() + + /// This function allows retrieving the androidx ViewModel from the store. + /// It uses the utility function to pass the generic type T to shared code + func viewModel( + key: String? = nil, + factory: ViewModelProviderFactory, + extras: CreationExtras? = nil + ) -> T { + do { + return try viewModelStore.resolveViewModel( + modelClass: T.self, + factory: factory, + key: key, + extras: extras + ) as! T + } catch { + fatalError("Failed to create ViewModel of type \(T.self)") + } + } + + /// This is called when this class is used as a `@StateObject` + deinit { + viewModelStore.clear() + } +} diff --git a/iosApp/iosApp/ListView.swift b/iosApp/iosApp/ListView.swift index 155e7c3..e5260ec 100644 --- a/iosApp/iosApp/ListView.swift +++ b/iosApp/iosApp/ListView.swift @@ -1,66 +1,89 @@ import SwiftUI import KMPNativeCoroutinesAsync -import KMPObservableViewModelSwiftUI import Shared struct ListView: View { - @StateViewModel - var viewModel = ListViewModel( - museumRepository: KoinDependencies().museumRepository - ) + @StateObject private var viewModelStoreOwner = IosViewModelStoreOwner() + + @State private var objects: [MuseumObject] = [] let columns = [ - GridItem(.adaptive(minimum: 120), alignment: .top) + GridItem(.adaptive(minimum: 180), alignment: .top) ] + private var viewModel: ListViewModel { + viewModelStoreOwner.viewModel(factory: KoinDependencies().listViewModelFactory) + } + var body: some View { ZStack { - if !viewModel.objects.isEmpty { + if !objects.isEmpty { NavigationStack { ScrollView { - LazyVGrid(columns: columns, alignment: .leading, spacing: 20) { - ForEach(viewModel.objects, id: \.self) { item in - NavigationLink(destination: DetailView(objectId: item.objectID)) { + LazyVGrid(columns: columns, spacing: 0) { + ForEach(objects, id: \.self) { item in + NavigationLink(value: item.objectID) { ObjectFrame(obj: item) } .buttonStyle(PlainButtonStyle()) } } - .padding(.horizontal) + } + .navigationDestination(for: Int32.self) { objectId in + DetailView(objectId: objectId) } } } else { - Text("No data available") + EmptyScreenContent() + } + } + .task { + await observeObjects() + } + } + + @MainActor + private func observeObjects() async { + do { + let stream = asyncSequence(for: viewModel.objectsFlow) + for try await newObjects in stream { + self.objects = newObjects } + } catch { + print("Failed observing objects: \(error)") } } } +struct EmptyScreenContent: View { + var body: some View { + Text("No data available") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + struct ObjectFrame: View { let obj: MuseumObject var body: some View { - VStack(alignment: .leading, spacing: 4) { - GeometryReader { geometry in - AsyncImage(url: URL(string: obj.primaryImageSmall)) { phase in - switch phase { - case .empty: - ProgressView() - .frame(width: geometry.size.width, height: geometry.size.width) - case .success(let image): - image - .resizable() - .scaledToFill() - .frame(width: geometry.size.width, height: geometry.size.width) - .clipped() - .aspectRatio(1, contentMode: .fill) - default: - EmptyView() - .frame(width: geometry.size.width, height: geometry.size.width) + VStack(alignment: .leading) { + Color(white: 0.9) + .aspectRatio(1, contentMode: .fit) + .overlay( + AsyncImage(url: URL(string: obj.primaryImageSmall)) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + default: + EmptyView() + } } - } - } - .aspectRatio(1, contentMode: .fit) + ) + .clipped() + + Spacer().frame(height: 2) Text(obj.title) .font(.headline) @@ -71,5 +94,6 @@ struct ObjectFrame: View { Text(obj.objectDate) .font(.caption) } + .padding(8) } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 80be8c3..bc5faba 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,4 +29,4 @@ dependencyResolutionManagement { } include(":shared") -include(":composeApp") +include(":androidApp") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 6ae4a1a..b6ddf51 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,17 +1,19 @@ -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.androidLibrary) + alias(libs.plugins.androidKmpLibrary) alias(libs.plugins.kotlinxSerialization) alias(libs.plugins.ksp) alias(libs.plugins.kmpNativeCoroutines) } kotlin { - androidTarget { - @OptIn(ExperimentalKotlinGradlePluginApi::class) + androidLibrary { + namespace = "com.jetbrains.kmpapp.shared" + compileSdk = 36 + minSdk = 24 + compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } @@ -25,6 +27,7 @@ kotlin { iosTarget.binaries.framework { baseName = "Shared" isStatic = true + export(libs.androidx.lifecycle.viewmodel) } } @@ -40,25 +43,12 @@ kotlin { implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.koin.core) - api(libs.kmp.observable.viewmodel) + api(libs.androidx.lifecycle.viewmodel) } - // Required by KMM-ViewModel + // Required for iOS interop all { languageSettings.optIn("kotlinx.cinterop.ExperimentalForeignApi") - languageSettings.optIn("kotlin.experimental.ExperimentalObjCName") } } } - -android { - namespace = "com.jetbrains.kmpapp.shared" - compileSdk = 35 - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - defaultConfig { - minSdk = 24 - } -} diff --git a/shared/src/commonMain/kotlin/com/jetbrains/kmpapp/screens/DetailViewModel.kt b/shared/src/commonMain/kotlin/com/jetbrains/kmpapp/screens/DetailViewModel.kt index b24107a..b4060bf 100644 --- a/shared/src/commonMain/kotlin/com/jetbrains/kmpapp/screens/DetailViewModel.kt +++ b/shared/src/commonMain/kotlin/com/jetbrains/kmpapp/screens/DetailViewModel.kt @@ -1,17 +1,17 @@ package com.jetbrains.kmpapp.screens +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.jetbrains.kmpapp.data.MuseumObject import com.jetbrains.kmpapp.data.MuseumRepository -import com.rickclephas.kmp.observableviewmodel.ViewModel -import com.rickclephas.kmp.observableviewmodel.stateIn import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn class DetailViewModel(private val museumRepository: MuseumRepository) : ViewModel() { private val objectId = MutableStateFlow(null) diff --git a/shared/src/commonMain/kotlin/com/jetbrains/kmpapp/screens/ListViewModel.kt b/shared/src/commonMain/kotlin/com/jetbrains/kmpapp/screens/ListViewModel.kt index 0fa11c6..4cf85fe 100644 --- a/shared/src/commonMain/kotlin/com/jetbrains/kmpapp/screens/ListViewModel.kt +++ b/shared/src/commonMain/kotlin/com/jetbrains/kmpapp/screens/ListViewModel.kt @@ -1,12 +1,13 @@ package com.jetbrains.kmpapp.screens +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.jetbrains.kmpapp.data.MuseumObject import com.jetbrains.kmpapp.data.MuseumRepository -import com.rickclephas.kmp.observableviewmodel.ViewModel -import com.rickclephas.kmp.observableviewmodel.stateIn import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn class ListViewModel(museumRepository: MuseumRepository) : ViewModel() { @NativeCoroutinesState diff --git a/shared/src/iosMain/kotlin/com/jetbrains/kmpapp/KoinDependencies.kt b/shared/src/iosMain/kotlin/com/jetbrains/kmpapp/KoinDependencies.kt index dd83496..0a78581 100644 --- a/shared/src/iosMain/kotlin/com/jetbrains/kmpapp/KoinDependencies.kt +++ b/shared/src/iosMain/kotlin/com/jetbrains/kmpapp/KoinDependencies.kt @@ -1,9 +1,26 @@ package com.jetbrains.kmpapp +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory import com.jetbrains.kmpapp.data.MuseumRepository +import com.jetbrains.kmpapp.screens.DetailViewModel +import com.jetbrains.kmpapp.screens.ListViewModel import org.koin.core.component.KoinComponent import org.koin.core.component.inject class KoinDependencies : KoinComponent { val museumRepository: MuseumRepository by inject() + + val listViewModelFactory: ViewModelProvider.Factory = viewModelFactory { + initializer { + ListViewModel(museumRepository) + } + } + + val detailViewModelFactory: ViewModelProvider.Factory = viewModelFactory { + initializer { + DetailViewModel(museumRepository) + } + } } diff --git a/shared/src/iosMain/kotlin/com/jetbrains/kmpapp/ViewModelResolver.kt b/shared/src/iosMain/kotlin/com/jetbrains/kmpapp/ViewModelResolver.kt new file mode 100644 index 0000000..845dec6 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/jetbrains/kmpapp/ViewModelResolver.kt @@ -0,0 +1,31 @@ +package com.jetbrains.kmpapp + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.viewmodel.CreationExtras +import kotlin.reflect.KClass +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ObjCClass +import kotlinx.cinterop.getOriginalKotlinClass + +/** + * This function allows retrieving any ViewModel from Swift Code with generics. We only get + * [ObjCClass] type for the [modelClass], because the interop between Kotlin and Swift code + * doesn't preserve the generic class, but we can retrieve the original KClass in Kotlin. + */ +@BetaInteropApi +@Throws(IllegalArgumentException::class) +fun ViewModelStore.resolveViewModel( + modelClass: ObjCClass, + factory: ViewModelProvider.Factory, + key: String?, + extras: CreationExtras? = null, +): ViewModel { + @Suppress("UNCHECKED_CAST") + val vmClass = getOriginalKotlinClass(modelClass) as? KClass + require(vmClass != null) { "The modelClass parameter must be a ViewModel type." } + + val provider = ViewModelProvider.create(this, factory, extras ?: CreationExtras.Empty) + return key?.let { provider[key, vmClass] } ?: provider[vmClass] +} From 7fbba6463e82ad338c6dad415b961774f733a507 Mon Sep 17 00:00:00 2001 From: utamori Date: Wed, 21 Jan 2026 16:03:13 +0900 Subject: [PATCH 2/2] Add lifecycle-viewmodel-navigation3 for proper ViewModel scoping --- androidApp/build.gradle.kts | 1 + androidApp/src/main/kotlin/com/jetbrains/kmpapp/App.kt | 4 ++++ gradle/libs.versions.toml | 1 + 3 files changed, 6 insertions(+) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index b23cec2..a06ce46 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(libs.androidx.viewmodel.compose) implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.koin.androidx.compose) implementation(libs.coil.compose) implementation(libs.coil.network.ktor) diff --git a/androidApp/src/main/kotlin/com/jetbrains/kmpapp/App.kt b/androidApp/src/main/kotlin/com/jetbrains/kmpapp/App.kt index 95eb745..8a496d3 100644 --- a/androidApp/src/main/kotlin/com/jetbrains/kmpapp/App.kt +++ b/androidApp/src/main/kotlin/com/jetbrains/kmpapp/App.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavEntry import androidx.navigation3.ui.NavDisplay import com.jetbrains.kmpapp.screens.DetailScreen @@ -31,6 +32,9 @@ fun App() { NavDisplay( backStack = backStack, onBack = { backStack.removeLastOrNull() }, + entryDecorators = listOf( + rememberViewModelStoreNavEntryDecorator(), + ), entryProvider = { key -> when (key) { is ListDestination -> NavEntry(key) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 407df89..4431a62 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ androidx-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel- coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } coil-network-ktor = { group = "io.coil-kt.coil3", name = "coil-network-ktor3", version.ref = "coil" } androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-viewmodelCompose" } +androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx-viewmodelCompose" } koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }