├── .editorconfig ├── .github └── workflows │ ├── build-android-app.yml │ └── build-desktop-app.yml ├── .gitignore ├── .run └── desktopApp.run.xml ├── LICENSE.txt ├── README.md ├── androidApp ├── build.gradle.kts └── src │ └── androidMain │ ├── AndroidManifest.xml │ ├── kotlin │ └── com │ │ └── hoc081098 │ │ └── compose_multiplatform_kmpviewmodel_sample │ │ ├── MainActivity.kt │ │ ├── MyApp.kt │ │ └── navigation.kt │ └── res │ ├── drawable-v24 │ └── ic_launcher_foreground.xml │ ├── drawable │ └── ic_launcher_background.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-mdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ └── values │ └── strings.xml ├── build-logic ├── build.gradle.kts ├── gradle.properties ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── settings.gradle.kts └── src │ └── main │ └── kotlin │ └── com │ └── hoc081098 │ └── compose_multiplatform_kmpviewmodel_sample │ ├── EmptyPlugin.kt │ ├── PropertiesMap.kt │ └── environment.kt ├── build.gradle.kts ├── cleanup.sh ├── core ├── common_shared │ ├── README.md │ ├── build.gradle.kts │ └── src │ │ └── commonMain │ │ └── kotlin │ │ └── com │ │ └── hoc081098 │ │ └── compose_multiplatform_kmpviewmodel_sample │ │ └── common_shared │ │ └── di.kt ├── common_ui_shared │ ├── README.md │ ├── build.gradle.kts │ └── src │ │ └── commonMain │ │ └── kotlin │ │ └── com │ │ └── hoc081098 │ │ └── compose_multiplatform_kmpviewmodel_sample │ │ └── common_ui │ │ ├── components │ │ ├── EmptyView.kt │ │ ├── ErrorMessageAndRetryButton.kt │ │ └── LoadingIndicator.kt │ │ └── theme │ │ ├── Color.kt │ │ └── Theme.kt └── navigation_shared │ ├── build.gradle.kts │ └── src │ └── commonMain │ └── kotlin │ └── com │ └── hoc081098 │ └── compose_multiplatform_kmpviewmodel_sample │ └── navigation_shared │ └── appRoutes.common.kt ├── desktopApp ├── build.gradle.kts └── src │ └── jvmMain │ ├── kotlin │ └── com │ │ └── hoc081098 │ │ └── compose_multiplatform_kmpviewmodel_sample │ │ ├── main.kt │ │ └── navigation.kt │ └── resources │ └── log4j.properties ├── features ├── feature_photo_detail_shared │ ├── build.gradle.kts │ └── src │ │ ├── androidMain │ │ ├── AndroidManifest.xml │ │ └── kotlin │ │ │ └── com │ │ │ └── hoc081098 │ │ │ └── compose_multiplatform_kmpviewmodel_sample │ │ │ └── photo_detail │ │ │ ├── data │ │ │ ├── PlatformPhotoDetailErrorMapper.android.kt │ │ │ └── di.android.kt │ │ │ └── main.android.kt │ │ ├── commonMain │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── hoc081098 │ │ │ │ └── compose_multiplatform_kmpviewmodel_sample │ │ │ │ └── photo_detail │ │ │ │ ├── data │ │ │ │ ├── RealPhotoDetailRepository.kt │ │ │ │ ├── di.kt │ │ │ │ ├── errorMapper.kt │ │ │ │ └── remote │ │ │ │ │ ├── KtorUnsplashApi.kt │ │ │ │ │ ├── UnsplashApi.kt │ │ │ │ │ ├── createHttpClient.kt │ │ │ │ │ └── response │ │ │ │ │ └── responses.kt │ │ │ │ ├── domain │ │ │ │ ├── GetPhotoDetailByIdUseCase.kt │ │ │ │ ├── PhotoDetail.kt │ │ │ │ ├── PhotoDetailError.kt │ │ │ │ ├── PhotoDetailRepository.kt │ │ │ │ └── di.kt │ │ │ │ ├── main.kt │ │ │ │ └── presentation │ │ │ │ ├── PhotoDetailPartialStateChange.kt │ │ │ │ ├── PhotoDetailScreen.kt │ │ │ │ ├── PhotoDetailUiState.kt │ │ │ │ ├── PhotoDetailViewIntent.kt │ │ │ │ ├── PhotoDetailViewModel.kt │ │ │ │ ├── components │ │ │ │ ├── CreatorInfoCard.kt │ │ │ │ └── LargePhotoImage.kt │ │ │ │ └── di.kt │ │ └── resources │ │ │ └── ic_unsplash_svg.xml │ │ ├── desktopMain │ │ └── kotlin │ │ │ └── com │ │ │ └── hoc081098 │ │ │ └── compose_multiplatform_kmpviewmodel_sample │ │ │ └── photo_detail │ │ │ ├── data │ │ │ ├── PlatformPhotoDetailErrorMapper.desktop.kt │ │ │ └── di.desktop.kt │ │ │ └── main.desktop.kt │ │ └── iosMain │ │ └── kotlin │ │ └── com │ │ └── hoc081098 │ │ └── compose_multiplatform_kmpviewmodel_sample │ │ └── photo_detail │ │ ├── data │ │ ├── PlatformPhotoDetailErrorMapper.ios.kt │ │ └── di.ios.kt │ │ └── main.ios.kt └── feature_search_photo_shared │ ├── build.gradle.kts │ └── src │ ├── androidMain │ ├── AndroidManifest.xml │ └── kotlin │ │ └── com │ │ └── hoc081098 │ │ └── compose_multiplatform_kmpviewmodel_sample │ │ └── search_photo │ │ ├── data │ │ ├── PlatformSearchPhotoErrorMapper.android.kt │ │ └── di.android.kt │ │ └── main.android.kt │ ├── commonMain │ ├── kotlin │ │ └── com │ │ │ └── hoc081098 │ │ │ └── compose_multiplatform_kmpviewmodel_sample │ │ │ └── search_photo │ │ │ ├── data │ │ │ ├── RealSearchPhotoRepository.kt │ │ │ ├── di.kt │ │ │ ├── errorMapper.kt │ │ │ └── remote │ │ │ │ ├── KtorUnsplashApi.kt │ │ │ │ ├── SearchPhotosResult.kt │ │ │ │ ├── UnsplashApi.kt │ │ │ │ ├── createHttpClient.kt │ │ │ │ └── response │ │ │ │ └── responses.kt │ │ │ ├── domain │ │ │ ├── CoverPhoto.kt │ │ │ ├── SearchPhotoError.kt │ │ │ ├── SearchPhotoRepository.kt │ │ │ ├── SearchPhotoUseCase.kt │ │ │ └── di.kt │ │ │ ├── main.kt │ │ │ └── presentation │ │ │ ├── SearchPhotoScreen.kt │ │ │ ├── SearchPhotoUiState.kt │ │ │ ├── SearchPhotoViewModel.kt │ │ │ ├── components │ │ │ └── PhotoGridCell.kt │ │ │ └── di.kt │ └── resources │ │ └── compose-multiplatform.xml │ ├── desktopMain │ └── kotlin │ │ └── com │ │ └── hoc081098 │ │ └── compose_multiplatform_kmpviewmodel_sample │ │ └── search_photo │ │ ├── data │ │ ├── PlatformSearchPhotoErrorMapper.desktop.kt │ │ └── di.desktop.kt │ │ └── main.desktop.kt │ └── iosMain │ └── kotlin │ └── com │ └── hoc081098 │ └── compose_multiplatform_kmpviewmodel_sample │ └── search_photo │ ├── data │ ├── PlatformSearchPhotoErrorMapper.ios.kt │ └── di.ios.kt │ └── main.ios.kt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── images ├── img_0.png └── img_1.png ├── libraries ├── compose-stable-wrappers │ ├── README.md │ ├── build.gradle.kts │ └── src │ │ └── commonMain │ │ └── kotlin │ │ └── com │ │ └── hoc081098 │ │ └── compose_multiplatform_kmpviewmodel_sample │ │ └── stable_wrappers │ │ └── stableWrappers.kt ├── coroutines-utils │ ├── README.md │ ├── build.gradle.kts │ └── src │ │ └── commonMain │ │ └── kotlin │ │ └── com │ │ └── hoc081098 │ │ └── compose_multiplatform_kmpviewmodel_sample │ │ └── coroutines_utils │ │ ├── AppCoroutineDispatchers.kt │ │ └── publishWithSelector.kt ├── koin-compose-utils │ ├── README.md │ ├── build.gradle.kts │ └── src │ │ └── commonMain │ │ └── kotlin │ │ └── com │ │ └── hoc081098 │ │ └── compose_multiplatform_kmpviewmodel_sample │ │ └── koin_compose_utils │ │ ├── koinInjectMapMultibinding.kt │ │ ├── koinInjectSetMultibinding.kt │ │ └── rememberKoinModulesForRoute.kt └── koin-utils │ ├── README.md │ ├── build.gradle.kts │ └── src │ └── commonMain │ └── kotlin │ └── com │ └── hoc081098 │ └── compose_multiplatform_kmpviewmodel_sample │ └── koin_utils │ ├── InternalNavigationApi.kt │ ├── koinMapMultibinding.kt │ └── koinSetMultibinding.kt ├── renovate.json5 └── settings.gradle.kts /.editorconfig: -------------------------------------------------------------------------------- 1 | root=true 2 | [*] 3 | indent_size=2 4 | end_of_line=lf 5 | charset=utf-8 6 | trim_trailing_whitespace=true 7 | insert_final_newline=true 8 | [*.{kt,kts}] 9 | ij_kotlin_imports_layout=* 10 | ij_continuation_indent_size=4 11 | ij_kotlin_allow_trailing_comma_on_call_site=true 12 | ij_kotlin_allow_trailing_comma=true 13 | ktlint_standard_filename=disabled 14 | ktlint_standard_package-name=disabled 15 | ktlint_standard_property-naming=disabled 16 | ktlint_standard_function-naming=disabled 17 | ktlint_experimental=disabled 18 | ktlint_code_style=ktlint_official 19 | ktlint_standard_argument-list-wrapping=disabled 20 | ktlint_standard_multiline-expression-wrapping=disabled 21 | ktlint_standard_string-template-indent=disabled 22 | max_line_length=120 23 | [*.kts] 24 | max_line_length=400 25 | ktlint_standard_chain-wrapping=disabled 26 | [*.xml] 27 | indent_size=4 28 | -------------------------------------------------------------------------------- /.github/workflows/build-android-app.yml: -------------------------------------------------------------------------------- 1 | name: Build Android App CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths-ignore: [ '**.md', '**.MD' ] 7 | pull_request: 8 | branches: [ main ] 9 | paths-ignore: [ '**.md', '**.MD' ] 10 | workflow_dispatch: 11 | 12 | env: 13 | CI: true 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Set up JDK 22 | uses: actions/setup-java@v4 23 | with: 24 | distribution: 'zulu' 25 | java-version: '17' 26 | 27 | - name: Setup Gradle 28 | uses: gradle/gradle-build-action@v3 29 | with: 30 | gradle-home-cache-cleanup: true 31 | 32 | - name: Make gradlew executable 33 | run: chmod +x ./gradlew 34 | 35 | - name: Build Android App 36 | run: ./gradlew :androidApp:assembleDebug --warning-mode all --stacktrace 37 | -------------------------------------------------------------------------------- /.github/workflows/build-desktop-app.yml: -------------------------------------------------------------------------------- 1 | name: Build Desktop App CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths-ignore: [ '**.md', '**.MD' ] 7 | pull_request: 8 | branches: [ main ] 9 | paths-ignore: [ '**.md', '**.MD' ] 10 | workflow_dispatch: 11 | 12 | env: 13 | CI: true 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Set up JDK 22 | uses: actions/setup-java@v4 23 | with: 24 | distribution: 'zulu' 25 | java-version: '17' 26 | 27 | - name: Setup Gradle 28 | uses: gradle/gradle-build-action@v3 29 | with: 30 | gradle-home-cache-cleanup: true 31 | 32 | - name: Make gradlew executable 33 | run: chmod +x ./gradlew 34 | 35 | - name: Build Desktop App 36 | run: ./gradlew :desktopApp:build --warning-mode all --stacktrace 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea 5 | .DS_Store 6 | build/ 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | iosApp/Podfile.lock 11 | iosApp/Pods/* 12 | iosApp/iosApp.xcworkspace/* 13 | iosApp/iosApp.xcodeproj/* 14 | !iosApp/iosApp.xcodeproj/project.pbxproj 15 | shared/shared.podspec 16 | -------------------------------------------------------------------------------- /.run/desktopApp.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 16 | 18 | true 19 | true 20 | false 21 | 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Compose-Multiplatform-KmpViewModel-Unsplash-Sample 2 | 3 | [![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Fhoc081098%2FCompose-Multiplatform-KmpViewModel-Unsplash-Sample&count_bg=%2379C83D&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false)](https://hits.seeyoufarm.com) 4 | [![Build Desktop App CI](https://github.com/hoc081098/Compose-Multiplatform-KmpViewModel-KMM-Unsplash-Sample/actions/workflows/build-desktop-app.yml/badge.svg)](https://github.com/hoc081098/Compose-Multiplatform-KmpViewModel-KMM-Unsplash-Sample/actions/workflows/build-desktop-app.yml) 5 | [![Build Android App CI](https://github.com/hoc081098/Compose-Multiplatform-KmpViewModel-Unsplash-Sample/actions/workflows/build-android-app.yml/badge.svg)](https://github.com/hoc081098/Compose-Multiplatform-KmpViewModel-Unsplash-Sample/actions/workflows/build-android-app.yml) 6 | [![Kotlin](https://img.shields.io/badge/kotlin-1.9.22-purple.svg?logo=kotlin)](http://kotlinlang.org) 7 | 8 | This repo is a template for getting started with Compose Multiplatform or Kotlin Multiplatform with support for Android, iOS, and Desktop. 9 | 10 | **Compose Multiplatform** sample: 11 | - https://github.com/hoc081098/kmp-viewmodel: Multiplatform ViewModel, SavedStateHandle 12 | - https://github.com/hoc081098/solivagant: Compose Multiplatform Navigation 13 | - https://github.com/JetBrains/compose-multiplatform 14 | 15 | Liked some of my work? Buy me a coffee (or more likely a beer) 16 | 17 | Buy Me A Coffee 18 | 19 | ### Modern Development 20 | 21 | - Kotlin Multiplatform 22 | - [JetBrains Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform) 23 | - [Kotlin Coroutines & Flows](https://github.com/hoc081098/FlowExt) 24 | - Koin Dependency Injection 25 | - Model-View-Intent (MVI) / FlowRedux state management 26 | - [Kotlin Multiplatform ViewModel](https://github.com/hoc081098/kmp-viewmodel) 27 | - Clean Architecture 28 | - Compose Multiplatform type-safe navigation by [solivagant](https://github.com/hoc081098/solivagant) 29 | 30 | ### Screenshots 31 | 32 | https://user-images.githubusercontent.com/36917223/270357793-11cb7264-59fe-4f58-884a-c92c204b566f.mov 33 | 34 | 35 | 36 |
37 | 38 | 39 | -------------------------------------------------------------------------------- /androidApp/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlin.multiplatform) 3 | alias(libs.plugins.android.app) 4 | alias( 5 | libs 6 | .plugins 7 | .jetbrains 8 | .compose 9 | .mutiplatform, 10 | ) 11 | } 12 | 13 | kotlin { 14 | jvmToolchain { 15 | languageVersion.set( 16 | JavaLanguageVersion.of( 17 | libs 18 | .versions 19 | .java 20 | .toolchain 21 | .get(), 22 | ), 23 | ) 24 | vendor.set(JvmVendorSpec.AZUL) 25 | } 26 | 27 | androidTarget() 28 | 29 | sourceSets { 30 | val androidMain by getting { 31 | dependencies { 32 | // Feature modules 33 | implementation(projects.features.featureSearchPhotoShared) 34 | implementation(projects.features.featurePhotoDetailShared) 35 | 36 | // Libraries 37 | implementation(projects.libraries.koinUtils) 38 | implementation(projects.libraries.koinComposeUtils) 39 | implementation(projects.libraries.coroutinesUtils) 40 | 41 | // Core 42 | implementation(projects.core.commonShared) 43 | implementation(projects.core.commonUiShared) 44 | implementation(projects.core.navigationShared) 45 | 46 | // Koin Android 47 | implementation(libs.koin.android) 48 | implementation(libs.koin.androidx.compose) 49 | } 50 | } 51 | } 52 | } 53 | 54 | android { 55 | namespace = "com.hoc081098.compose_multiplatform_kmpviewmodel_sample" 56 | sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") 57 | 58 | compileSdk = 59 | libs 60 | .versions 61 | .android 62 | .compile 63 | .map { it.toInt() } 64 | .get() 65 | defaultConfig { 66 | applicationId = "com.hoc081098.compose_multiplatform_kmpviewmodel_sample" 67 | minSdk = 68 | libs 69 | .versions 70 | .android 71 | .min 72 | .map { it.toInt() } 73 | .get() 74 | targetSdk = 75 | libs 76 | .versions 77 | .android 78 | .target 79 | .map { it.toInt() } 80 | .get() 81 | versionCode = 1 82 | versionName = "1.0" 83 | } 84 | 85 | compileOptions { 86 | sourceCompatibility = 87 | JavaVersion.toVersion( 88 | libs 89 | .versions 90 | .java 91 | .target 92 | .get(), 93 | ) 94 | targetCompatibility = 95 | JavaVersion.toVersion( 96 | libs 97 | .versions 98 | .java 99 | .target 100 | .get(), 101 | ) 102 | } 103 | 104 | packaging { 105 | // Copy from https://github.com/slackhq/slack-gradle-plugin/blob/cc5cb94272d610e68a3ee089d73a3a4794221d05/slack-plugin/src/main/kotlin/slack/gradle/StandardProjectConfigurations.kt#L682 106 | resources.excludes += 107 | setOf( 108 | "META-INF/LICENSE.txt", 109 | "META-INF/LICENSE", 110 | "META-INF/NOTICE.txt", 111 | ".readme", 112 | "META-INF/maven/com.google.guava/guava/pom.properties", 113 | "META-INF/maven/com.google.guava/guava/pom.xml", 114 | "META-INF/DEPENDENCIES", 115 | "**/*.pro", 116 | "**/*.proto", 117 | // Weird bazel build metadata brought in by Tink 118 | "build-data.properties", 119 | "LICENSE_*", 120 | // We don't know where this comes from but it's 5MB 121 | // https://slack-pde.slack.com/archives/C8EER3C04/p1621353426001500 122 | "annotated-jdk/**", 123 | "META-INF/versions/9/previous-compilation-data.bin", 124 | ) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /androidApp/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /androidApp/src/androidMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample 2 | 3 | import android.os.Bundle 4 | import androidx.activity.compose.setContent 5 | import androidx.appcompat.app.AppCompatActivity 6 | import androidx.compose.runtime.remember 7 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.common_ui.theme.AppTheme 8 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.koin_compose_utils.koinInjectSetMultibinding 9 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.navigation_shared.SearchPhotoScreenRoute 10 | import com.hoc081098.solivagant.navigation.NavDestination 11 | import com.hoc081098.solivagant.navigation.NavHost 12 | import io.github.aakira.napier.Napier 13 | import kotlinx.collections.immutable.toImmutableSet 14 | import org.koin.androidx.compose.KoinAndroidContext 15 | import org.koin.compose.koinInject 16 | import org.koin.core.annotation.KoinExperimentalAPI 17 | 18 | @OptIn(KoinExperimentalAPI::class) 19 | class MainActivity : AppCompatActivity() { 20 | override fun onCreate(savedInstanceState: Bundle?) { 21 | super.onCreate(savedInstanceState) 22 | 23 | setContent { 24 | KoinAndroidContext { 25 | AppTheme { 26 | NavHost( 27 | startRoute = SearchPhotoScreenRoute, 28 | destinations = koinInjectSetMultibinding(AllDestinationsQualifier) 29 | .let { remember(it) { it.toImmutableSet() } }, 30 | navEventNavigator = koinInject(), 31 | destinationChangedCallback = { route -> 32 | Napier.d(message = "Destination changed: $route", tag = "MainActivity") 33 | }, 34 | ) 35 | } 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /androidApp/src/androidMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/MyApp.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample 2 | 3 | import android.app.Application 4 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.common_shared.CommonModule 5 | import io.github.aakira.napier.DebugAntilog 6 | import io.github.aakira.napier.Napier 7 | import org.koin.android.ext.koin.androidContext 8 | import org.koin.android.ext.koin.androidLogger 9 | import org.koin.core.context.startKoin 10 | import org.koin.core.logger.Level 11 | 12 | class MyApp : Application() { 13 | override fun onCreate() { 14 | super.onCreate() 15 | 16 | startKoin { 17 | androidLogger(level = Level.DEBUG) 18 | 19 | androidContext(this@MyApp) 20 | 21 | modules( 22 | NavigationModule, 23 | CommonModule, 24 | ) 25 | } 26 | 27 | Napier.base(DebugAntilog()) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /androidApp/src/androidMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/navigation.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample 2 | 3 | import androidx.compose.runtime.Stable 4 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.koin_utils.declareSetMultibinding 5 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.koin_utils.intoSetMultibinding 6 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.navigation_shared.PhotoDetailScreenRoute 7 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.navigation_shared.SearchPhotoScreenRoute 8 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.PhotoDetailScreen 9 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.SearchPhotoScreen 10 | import com.hoc081098.solivagant.navigation.NavDestination 11 | import com.hoc081098.solivagant.navigation.NavEventNavigator 12 | import com.hoc081098.solivagant.navigation.ScreenDestination 13 | import org.koin.core.module.dsl.singleOf 14 | import org.koin.core.qualifier.qualifier 15 | import org.koin.dsl.module 16 | 17 | @Stable 18 | @JvmField 19 | val AllDestinationsQualifier = qualifier("AllDestinationsQualifier") 20 | 21 | @JvmField 22 | val NavigationModule = 23 | module { 24 | singleOf(::NavEventNavigator) 25 | 26 | declareSetMultibinding(qualifier = AllDestinationsQualifier) 27 | 28 | intoSetMultibinding( 29 | key = SearchPhotoScreenRoute::class.java, 30 | multibindingQualifier = AllDestinationsQualifier, 31 | ) { 32 | ScreenDestination { route, modifier -> 33 | SearchPhotoScreen(modifier = modifier, route = route) 34 | } 35 | } 36 | 37 | intoSetMultibinding( 38 | key = PhotoDetailScreenRoute::class.java, 39 | multibindingQualifier = AllDestinationsQualifier, 40 | ) { 41 | ScreenDestination { route, modifier -> 42 | PhotoDetailScreen(modifier = modifier, route = route) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /androidApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /androidApp/src/androidMain/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /androidApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /androidApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /androidApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/Compose-Multiplatform-KmpViewModel-Unsplash-Sample/a9f88e18690fc2401a8f936a9792a584a84d2991/androidApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/Compose-Multiplatform-KmpViewModel-Unsplash-Sample/a9f88e18690fc2401a8f936a9792a584a84d2991/androidApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /androidApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/Compose-Multiplatform-KmpViewModel-Unsplash-Sample/a9f88e18690fc2401a8f936a9792a584a84d2991/androidApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/Compose-Multiplatform-KmpViewModel-Unsplash-Sample/a9f88e18690fc2401a8f936a9792a584a84d2991/androidApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /androidApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/Compose-Multiplatform-KmpViewModel-Unsplash-Sample/a9f88e18690fc2401a8f936a9792a584a84d2991/androidApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/Compose-Multiplatform-KmpViewModel-Unsplash-Sample/a9f88e18690fc2401a8f936a9792a584a84d2991/androidApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /androidApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/Compose-Multiplatform-KmpViewModel-Unsplash-Sample/a9f88e18690fc2401a8f936a9792a584a84d2991/androidApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/Compose-Multiplatform-KmpViewModel-Unsplash-Sample/a9f88e18690fc2401a8f936a9792a584a84d2991/androidApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /androidApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/Compose-Multiplatform-KmpViewModel-Unsplash-Sample/a9f88e18690fc2401a8f936a9792a584a84d2991/androidApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/Compose-Multiplatform-KmpViewModel-Unsplash-Sample/a9f88e18690fc2401a8f936a9792a584a84d2991/androidApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /androidApp/src/androidMain/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | KMPViewModel Compose Multiplatform 3 | 4 | -------------------------------------------------------------------------------- /build-logic/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | 3 | plugins { 4 | `kotlin-dsl` 5 | `kotlin-dsl-precompiled-script-plugins` 6 | } 7 | 8 | group = "com.hoc081098.compose_multiplatform_kmpviewmodel_sample.build-logic" 9 | 10 | kotlin { 11 | sourceSets { 12 | all { 13 | languageSettings { 14 | optIn("kotlin.RequiresOptIn") 15 | } 16 | } 17 | } 18 | 19 | jvmToolchain { 20 | languageVersion.set( 21 | JavaLanguageVersion.of( 22 | libs 23 | .versions 24 | .java 25 | .toolchain 26 | .get(), 27 | ), 28 | ) 29 | vendor.set(JvmVendorSpec.AZUL) 30 | } 31 | } 32 | 33 | tasks.withType().configureEach { 34 | compilerOptions { 35 | // Use the same java toolchain version for compilation as the Kotlin plugin 36 | jvmTarget.set( 37 | JvmTarget.fromTarget( 38 | libs 39 | .versions 40 | .java 41 | .toolchain 42 | .get(), 43 | ), 44 | ) 45 | } 46 | } 47 | 48 | tasks.withType().configureEach { 49 | // Use the same java toolchain version for compilation as the Kotlin plugin 50 | sourceCompatibility = 51 | JavaVersion 52 | .toVersion( 53 | libs 54 | .versions 55 | .java 56 | .toolchain 57 | .get(), 58 | ).toString() 59 | targetCompatibility = 60 | JavaVersion 61 | .toVersion( 62 | libs 63 | .versions 64 | .java 65 | .toolchain 66 | .get(), 67 | ).toString() 68 | } 69 | 70 | dependencies { 71 | // TODO: remove once https://github.com/gradle/gradle/issues/15383#issuecomment-779893192 is fixed 72 | implementation( 73 | files( 74 | libs 75 | .javaClass 76 | .superclass 77 | .protectionDomain 78 | .codeSource 79 | .location, 80 | ), 81 | ) 82 | } 83 | 84 | gradlePlugin { 85 | plugins { 86 | register("empty") { 87 | id = "compose_multiplatform_kmpviewmodel_sample.empty" 88 | implementationClass = "com.hoc081098.compose_multiplatform_kmpviewmodel_sample.EmptyPlugin" 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /build-logic/gradle.properties: -------------------------------------------------------------------------------- 1 | #Gradle 2 | org.gradle.jvmargs=-Xmx4096M -Dkotlin.daemon.jvm.options\="-Xmx4096M" 3 | # When configured, Gradle will run in incubating parallel mode. 4 | # This option should only be used with decoupled projects. More details, visit 5 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 6 | org.gradle.parallel=true 7 | org.gradle.configureondemand=true 8 | # Enable the Build Cache 9 | org.gradle.caching=true 10 | 11 | #Kotlin 12 | kotlin.code.style=official 13 | 14 | # Enable Kotlin incremental compilation 15 | kotlin.incremental.multiplatform=true 16 | kotlin.incremental.useClasspathSnapshot=true 17 | kotlin.incremental=true 18 | 19 | #MPP 20 | kotlin.mpp.stability.nowarn=true 21 | kotlin.mpp.enableCInteropCommonization=true 22 | kotlin.mpp.androidSourceSetLayoutVersion=2 23 | 24 | #Compose 25 | org.jetbrains.compose.experimental.uikit.enabled=true 26 | 27 | #Android 28 | android.useAndroidX=true 29 | android.compileSdk=34 30 | android.targetSdk=34 31 | android.minSdk=24 32 | 33 | # Use R8 instead of ProGuard for code shrinking. 34 | android.enableR8.fullMode=true 35 | 36 | # Enable non-transitive R class namespacing where each library only contains 37 | # references to the resources it declares instead of declarations plus all 38 | # transitive dependency references. 39 | android.nonTransitiveRClass=true 40 | 41 | # Default Android build features 42 | android.defaults.buildfeatures.buildconfig=false 43 | android.defaults.buildfeatures.shaders=false 44 | 45 | # do not import irrelevant source sets 46 | import_orphan_source_sets=false 47 | 48 | #Versions 49 | kotlin.version=1.9.0 50 | agp.version=7.4.2 51 | compose.version=1.5.0 52 | 53 | #Build konfig 54 | buildkonfig.flavor=dev 55 | -------------------------------------------------------------------------------- /build-logic/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/Compose-Multiplatform-KmpViewModel-Unsplash-Sample/a9f88e18690fc2401a8f936a9792a584a84d2991/build-logic/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /build-logic/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /build-logic/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencyResolutionManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | } 6 | 7 | versionCatalogs { 8 | create("libs") { 9 | from(files("../gradle/libs.versions.toml")) 10 | } 11 | } 12 | } 13 | 14 | rootProject.name = "build-logic" 15 | -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/EmptyPlugin.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample 2 | 3 | import org.gradle.api.Plugin 4 | import org.gradle.api.Project 5 | 6 | class EmptyPlugin : Plugin { 7 | override fun apply(target: Project) = Unit 8 | } 9 | -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/PropertiesMap.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample 2 | 3 | import java.util.Properties 4 | import org.gradle.api.Project 5 | 6 | public interface PropertiesMap : Map { 7 | override operator fun get(key: String): String 8 | } 9 | 10 | private class DefaultPropertiesMap( 11 | val inner: Map, 12 | ) : PropertiesMap, 13 | Map by inner { 14 | override fun get(key: String): String = inner[key] ?: error("Key $key not found") 15 | } 16 | 17 | private fun Map.toPropertiesMap(): PropertiesMap = DefaultPropertiesMap(this) 18 | 19 | fun Project.readPropertiesFile(pathFromRootProject: String): PropertiesMap = 20 | Properties() 21 | .apply { 22 | load( 23 | rootProject 24 | .file(pathFromRootProject) 25 | .apply { 26 | check(exists()) { 27 | "$pathFromRootProject file not found. " + 28 | "Create $pathFromRootProject file from root project." 29 | } 30 | }.reader(), 31 | ) 32 | }.map { it.key as String to it.value as String } 33 | .toMap() 34 | .toPropertiesMap() 35 | -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/environment.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample 2 | 3 | import org.gradle.api.Project 4 | 5 | val Project.isCiBuild: Boolean 6 | get() = providers.environmentVariable("CI").orNull == "true" 7 | 8 | fun Project.envOrProp(name: String): String = 9 | providers.environmentVariable(name).orNull 10 | ?: providers.gradleProperty(name).getOrElse("") 11 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | // this is necessary to avoid the plugins to be loaded multiple times 3 | // in each subproject's classloader 4 | alias(libs.plugins.kotlin.multiplatform) apply false 5 | alias(libs.plugins.kotlin.serialization) apply false 6 | alias(libs.plugins.kotlin.parcelize) apply false 7 | 8 | alias(libs.plugins.android.app) apply false 9 | alias(libs.plugins.android.library) apply false 10 | 11 | alias( 12 | libs 13 | .plugins 14 | .jetbrains 15 | .compose 16 | .mutiplatform, 17 | ) apply false 18 | 19 | alias(libs.plugins.buildkonfig) apply false 20 | alias(libs.plugins.ksp) apply false 21 | alias(libs.plugins.spotless) apply false 22 | } 23 | 24 | allprojects { 25 | configurations.all { 26 | resolutionStrategy.eachDependency { 27 | if (requested.group == "io.github.hoc081098") { 28 | // Check for updates every build 29 | resolutionStrategy.cacheChangingModulesFor(30, TimeUnit.MINUTES) 30 | } 31 | } 32 | } 33 | 34 | apply() 35 | configure { 36 | val ktlintVersion = 37 | rootProject 38 | .libs 39 | .versions 40 | .ktlint 41 | .get() 42 | 43 | kotlin { 44 | target("**/*.kt") 45 | targetExclude("**/build/**/*.kt", "**/.gradle/**/*.kt") 46 | 47 | ktlint(ktlintVersion) 48 | .setEditorConfigPath(rootProject.file(".editorconfig")) 49 | 50 | trimTrailingWhitespace() 51 | indentWithSpaces() 52 | endWithNewline() 53 | } 54 | 55 | format("xml") { 56 | target("**/res/**/*.xml") 57 | targetExclude("**/build/**/*.xml", "**/.idea/**/*.xml", "**/.gradle/**/*.xml") 58 | 59 | trimTrailingWhitespace() 60 | indentWithSpaces() 61 | endWithNewline() 62 | lineEndings = 63 | com 64 | .diffplug 65 | .spotless 66 | .LineEnding 67 | .UNIX 68 | } 69 | 70 | kotlinGradle { 71 | target("**/*.gradle.kts", "*.gradle.kts") 72 | targetExclude("**/build/**/*.kts", "**/.gradle/**/*.kts") 73 | 74 | ktlint(ktlintVersion) 75 | .setEditorConfigPath(rootProject.file(".editorconfig")) 76 | 77 | trimTrailingWhitespace() 78 | indentWithSpaces() 79 | endWithNewline() 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /cleanup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | rm -rf .idea 3 | ./gradlew clean 4 | rm -rf .gradle 5 | rm -rf build 6 | rm -rf */build 7 | rm -rf iosApp/iosApp.xcworkspace 8 | rm -rf iosApp/Pods 9 | rm -rf iosApp/iosApp.xcodeproj/project.xcworkspace 10 | rm -rf iosApp/iosApp.xcodeproj/xcuserdata 11 | -------------------------------------------------------------------------------- /core/common_shared/README.md: -------------------------------------------------------------------------------- 1 | # Koin Utils Module 2 | 3 | ## Dependencies 4 | 5 | - Pure Kotlin. 6 | - No Compose Multiplatform plugin and dependencies. 7 | - Depends on: 8 | - `koin-core`. 9 | - `atomicfu`. 10 | 11 | ## Content 12 | 13 | - Koin multibinding utils: 14 | - Into set 15 | - Into map 16 | -------------------------------------------------------------------------------- /core/common_shared/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlin.multiplatform) 3 | } 4 | 5 | @OptIn( 6 | org 7 | .jetbrains 8 | .kotlin 9 | .gradle 10 | .ExperimentalKotlinGradlePluginApi::class, 11 | ) 12 | kotlin { 13 | jvmToolchain { 14 | languageVersion.set( 15 | JavaLanguageVersion.of( 16 | libs 17 | .versions 18 | .java 19 | .toolchain 20 | .get(), 21 | ), 22 | ) 23 | vendor.set(JvmVendorSpec.AZUL) 24 | } 25 | 26 | applyDefaultHierarchyTemplate() 27 | 28 | jvm() 29 | 30 | iosX64() 31 | iosArm64() 32 | iosSimulatorArm64() 33 | 34 | sourceSets { 35 | val commonMain by getting { 36 | dependencies { 37 | // Koin 38 | api(libs.koin.core) 39 | 40 | // Coroutines utils 41 | api(projects.libraries.coroutinesUtils) 42 | } 43 | } 44 | val commonTest by getting { 45 | dependencies { 46 | implementation(kotlin("test")) 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /core/common_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/common_shared/di.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.common_shared 2 | 3 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.coroutines_utils.AppCoroutineDispatchers 4 | import kotlin.jvm.JvmField 5 | import org.koin.core.module.dsl.singleOf 6 | import org.koin.dsl.module 7 | 8 | @JvmField 9 | val CommonModule = 10 | module { 11 | singleOf(::AppCoroutineDispatchers) 12 | } 13 | -------------------------------------------------------------------------------- /core/common_ui_shared/README.md: -------------------------------------------------------------------------------- 1 | # Common UI Shared Module 2 | 3 | ## Dependencies 4 | 5 | - Pure Kotlin. 6 | - Compose Multiplatform plugin. 7 | - Depends on 8 | - `compose-runtime`. 9 | - `compose-foundation`. 10 | - `compose-material3`. 11 | - `compose-resources`. 12 | 13 | ## Content 14 | 15 | - `AppTheme` 16 | - Common `@Composable`s. 17 | 18 | -------------------------------------------------------------------------------- /core/common_ui_shared/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlin.multiplatform) 3 | alias( 4 | libs 5 | .plugins 6 | .jetbrains 7 | .compose 8 | .mutiplatform, 9 | ) 10 | } 11 | 12 | @OptIn( 13 | org 14 | .jetbrains 15 | .kotlin 16 | .gradle 17 | .ExperimentalKotlinGradlePluginApi::class, 18 | ) 19 | kotlin { 20 | jvmToolchain { 21 | languageVersion.set( 22 | JavaLanguageVersion.of( 23 | libs 24 | .versions 25 | .java 26 | .toolchain 27 | .get(), 28 | ), 29 | ) 30 | vendor.set(JvmVendorSpec.AZUL) 31 | } 32 | 33 | applyDefaultHierarchyTemplate() 34 | 35 | jvm() 36 | 37 | iosX64() 38 | iosArm64() 39 | iosSimulatorArm64() 40 | 41 | sourceSets { 42 | val commonMain by getting { 43 | dependencies { 44 | implementation(compose.runtime) 45 | implementation(compose.foundation) 46 | implementation(compose.material3) 47 | @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) 48 | implementation(compose.components.resources) 49 | } 50 | } 51 | val commonTest by getting { 52 | dependencies { 53 | implementation(kotlin("test")) 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /core/common_ui_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/common_ui/components/EmptyView.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.common_ui.components 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.material3.Text 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Alignment 7 | import androidx.compose.ui.Modifier 8 | 9 | @Composable 10 | fun EmptyView( 11 | modifier: Modifier = Modifier, 12 | text: String = "Empty", 13 | ) { 14 | Box( 15 | modifier = modifier, 16 | contentAlignment = Alignment.Center, 17 | ) { 18 | Text(text = text) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /core/common_ui_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/common_ui/components/ErrorMessageAndRetryButton.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.common_ui.components 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.material3.Button 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.unit.dp 15 | 16 | @Composable 17 | fun ErrorMessageAndRetryButton( 18 | errorMessage: String, 19 | onRetry: () -> Unit, 20 | modifier: Modifier = Modifier, 21 | retryText: String = "Retry", 22 | ) { 23 | Box( 24 | modifier = modifier, 25 | contentAlignment = Alignment.Center, 26 | ) { 27 | Column( 28 | modifier = Modifier.fillMaxWidth(), 29 | verticalArrangement = Arrangement.Center, 30 | horizontalAlignment = Alignment.CenterHorizontally, 31 | ) { 32 | Text(text = errorMessage) 33 | 34 | Spacer(modifier = Modifier.height(16.dp)) 35 | 36 | Button(onClick = onRetry) { 37 | Text(text = retryText) 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /core/common_ui_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/common_ui/components/LoadingIndicator.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.common_ui.components 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.material3.CircularProgressIndicator 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Alignment 7 | import androidx.compose.ui.Modifier 8 | 9 | @Composable 10 | fun LoadingIndicator(modifier: Modifier = Modifier) { 11 | Box( 12 | modifier = modifier, 13 | contentAlignment = Alignment.Center, 14 | ) { 15 | CircularProgressIndicator() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /core/common_ui_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/common_ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.common_ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val md_theme_light_primary = Color(0xFFB02E00) 6 | val md_theme_light_onPrimary = Color(0xFFFFFFFF) 7 | val md_theme_light_primaryContainer = Color(0xFFFFDBD1) 8 | val md_theme_light_onPrimaryContainer = Color(0xFF3B0900) 9 | val md_theme_light_secondary = Color(0xFF865300) 10 | val md_theme_light_onSecondary = Color(0xFFFFFFFF) 11 | val md_theme_light_secondaryContainer = Color(0xFFFFDDB9) 12 | val md_theme_light_onSecondaryContainer = Color(0xFF2B1700) 13 | val md_theme_light_tertiary = Color(0xFF6C5D2F) 14 | val md_theme_light_onTertiary = Color(0xFFFFFFFF) 15 | val md_theme_light_tertiaryContainer = Color(0xFFF6E1A7) 16 | val md_theme_light_onTertiaryContainer = Color(0xFF231B00) 17 | val md_theme_light_error = Color(0xFFBA1A1A) 18 | val md_theme_light_errorContainer = Color(0xFFFFDAD6) 19 | val md_theme_light_onError = Color(0xFFFFFFFF) 20 | val md_theme_light_onErrorContainer = Color(0xFF410002) 21 | val md_theme_light_background = Color(0xFFFFFBFF) 22 | val md_theme_light_onBackground = Color(0xFF201A18) 23 | val md_theme_light_surface = Color(0xFFFFFBFF) 24 | val md_theme_light_onSurface = Color(0xFF201A18) 25 | val md_theme_light_surfaceVariant = Color(0xFFF5DED8) 26 | val md_theme_light_onSurfaceVariant = Color(0xFF53433F) 27 | val md_theme_light_outline = Color(0xFF85736E) 28 | val md_theme_light_inverseOnSurface = Color(0xFFFBEEEB) 29 | val md_theme_light_inverseSurface = Color(0xFF362F2D) 30 | val md_theme_light_inversePrimary = Color(0xFFFFB5A0) 31 | val md_theme_light_shadow = Color(0xFF000000) 32 | val md_theme_light_surfaceTint = Color(0xFFB02E00) 33 | val md_theme_light_outlineVariant = Color(0xFFD8C2BC) 34 | val md_theme_light_scrim = Color(0xFF000000) 35 | 36 | val md_theme_dark_primary = Color(0xFFFFB5A0) 37 | val md_theme_dark_onPrimary = Color(0xFF5F1500) 38 | val md_theme_dark_primaryContainer = Color(0xFF872100) 39 | val md_theme_dark_onPrimaryContainer = Color(0xFFFFDBD1) 40 | val md_theme_dark_secondary = Color(0xFFFFB961) 41 | val md_theme_dark_onSecondary = Color(0xFF472A00) 42 | val md_theme_dark_secondaryContainer = Color(0xFF663E00) 43 | val md_theme_dark_onSecondaryContainer = Color(0xFFFFDDB9) 44 | val md_theme_dark_tertiary = Color(0xFFD9C58D) 45 | val md_theme_dark_onTertiary = Color(0xFF3B2F05) 46 | val md_theme_dark_tertiaryContainer = Color(0xFF534619) 47 | val md_theme_dark_onTertiaryContainer = Color(0xFFF6E1A7) 48 | val md_theme_dark_error = Color(0xFFFFB4AB) 49 | val md_theme_dark_errorContainer = Color(0xFF93000A) 50 | val md_theme_dark_onError = Color(0xFF690005) 51 | val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) 52 | val md_theme_dark_background = Color(0xFF201A18) 53 | val md_theme_dark_onBackground = Color(0xFFEDE0DD) 54 | val md_theme_dark_surface = Color(0xFF201A18) 55 | val md_theme_dark_onSurface = Color(0xFFEDE0DD) 56 | val md_theme_dark_surfaceVariant = Color(0xFF53433F) 57 | val md_theme_dark_onSurfaceVariant = Color(0xFFD8C2BC) 58 | val md_theme_dark_outline = Color(0xFFA08C87) 59 | val md_theme_dark_inverseOnSurface = Color(0xFF201A18) 60 | val md_theme_dark_inverseSurface = Color(0xFFEDE0DD) 61 | val md_theme_dark_inversePrimary = Color(0xFFB02E00) 62 | val md_theme_dark_shadow = Color(0xFF000000) 63 | val md_theme_dark_surfaceTint = Color(0xFFFFB5A0) 64 | val md_theme_dark_outlineVariant = Color(0xFF53433F) 65 | val md_theme_dark_scrim = Color(0xFF000000) 66 | 67 | val seed = Color(0xFFF4511E) 68 | -------------------------------------------------------------------------------- /core/common_ui_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/common_ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.common_ui.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.material3.darkColorScheme 6 | import androidx.compose.material3.lightColorScheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.Stable 9 | 10 | @Stable 11 | private val LightColors = 12 | lightColorScheme( 13 | primary = md_theme_light_primary, 14 | onPrimary = md_theme_light_onPrimary, 15 | primaryContainer = md_theme_light_primaryContainer, 16 | onPrimaryContainer = md_theme_light_onPrimaryContainer, 17 | secondary = md_theme_light_secondary, 18 | onSecondary = md_theme_light_onSecondary, 19 | secondaryContainer = md_theme_light_secondaryContainer, 20 | onSecondaryContainer = md_theme_light_onSecondaryContainer, 21 | tertiary = md_theme_light_tertiary, 22 | onTertiary = md_theme_light_onTertiary, 23 | tertiaryContainer = md_theme_light_tertiaryContainer, 24 | onTertiaryContainer = md_theme_light_onTertiaryContainer, 25 | error = md_theme_light_error, 26 | errorContainer = md_theme_light_errorContainer, 27 | onError = md_theme_light_onError, 28 | onErrorContainer = md_theme_light_onErrorContainer, 29 | background = md_theme_light_background, 30 | onBackground = md_theme_light_onBackground, 31 | surface = md_theme_light_surface, 32 | onSurface = md_theme_light_onSurface, 33 | surfaceVariant = md_theme_light_surfaceVariant, 34 | onSurfaceVariant = md_theme_light_onSurfaceVariant, 35 | outline = md_theme_light_outline, 36 | inverseOnSurface = md_theme_light_inverseOnSurface, 37 | inverseSurface = md_theme_light_inverseSurface, 38 | inversePrimary = md_theme_light_inversePrimary, 39 | surfaceTint = md_theme_light_surfaceTint, 40 | outlineVariant = md_theme_light_outlineVariant, 41 | scrim = md_theme_light_scrim, 42 | ) 43 | 44 | @Stable 45 | private val DarkColors = 46 | darkColorScheme( 47 | primary = md_theme_dark_primary, 48 | onPrimary = md_theme_dark_onPrimary, 49 | primaryContainer = md_theme_dark_primaryContainer, 50 | onPrimaryContainer = md_theme_dark_onPrimaryContainer, 51 | secondary = md_theme_dark_secondary, 52 | onSecondary = md_theme_dark_onSecondary, 53 | secondaryContainer = md_theme_dark_secondaryContainer, 54 | onSecondaryContainer = md_theme_dark_onSecondaryContainer, 55 | tertiary = md_theme_dark_tertiary, 56 | onTertiary = md_theme_dark_onTertiary, 57 | tertiaryContainer = md_theme_dark_tertiaryContainer, 58 | onTertiaryContainer = md_theme_dark_onTertiaryContainer, 59 | error = md_theme_dark_error, 60 | errorContainer = md_theme_dark_errorContainer, 61 | onError = md_theme_dark_onError, 62 | onErrorContainer = md_theme_dark_onErrorContainer, 63 | background = md_theme_dark_background, 64 | onBackground = md_theme_dark_onBackground, 65 | surface = md_theme_dark_surface, 66 | onSurface = md_theme_dark_onSurface, 67 | surfaceVariant = md_theme_dark_surfaceVariant, 68 | onSurfaceVariant = md_theme_dark_onSurfaceVariant, 69 | outline = md_theme_dark_outline, 70 | inverseOnSurface = md_theme_dark_inverseOnSurface, 71 | inverseSurface = md_theme_dark_inverseSurface, 72 | inversePrimary = md_theme_dark_inversePrimary, 73 | surfaceTint = md_theme_dark_surfaceTint, 74 | outlineVariant = md_theme_dark_outlineVariant, 75 | scrim = md_theme_dark_scrim, 76 | ) 77 | 78 | @Composable 79 | fun AppTheme( 80 | useDarkTheme: Boolean = isSystemInDarkTheme(), 81 | content: @Composable () -> Unit, 82 | ) { 83 | val colors = 84 | if (!useDarkTheme) { 85 | LightColors 86 | } else { 87 | DarkColors 88 | } 89 | 90 | MaterialTheme( 91 | colorScheme = colors, 92 | content = content, 93 | ) 94 | } 95 | -------------------------------------------------------------------------------- /core/navigation_shared/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlin.multiplatform) 3 | alias(libs.plugins.android.library) 4 | alias(libs.plugins.kotlin.parcelize) 5 | } 6 | 7 | @OptIn( 8 | org 9 | .jetbrains 10 | .kotlin 11 | .gradle 12 | .ExperimentalKotlinGradlePluginApi::class, 13 | ) 14 | kotlin { 15 | jvmToolchain { 16 | languageVersion.set( 17 | JavaLanguageVersion.of( 18 | libs 19 | .versions 20 | .java 21 | .toolchain 22 | .get(), 23 | ), 24 | ) 25 | vendor.set(JvmVendorSpec.AZUL) 26 | } 27 | 28 | applyDefaultHierarchyTemplate() 29 | 30 | androidTarget { 31 | compilations.all { 32 | kotlinOptions { 33 | jvmTarget = 34 | JavaVersion 35 | .toVersion( 36 | libs 37 | .versions 38 | .java 39 | .target 40 | .get(), 41 | ).toString() 42 | } 43 | } 44 | } 45 | 46 | jvm() 47 | 48 | iosX64() 49 | iosArm64() 50 | iosSimulatorArm64() 51 | 52 | sourceSets { 53 | val commonMain by getting { 54 | dependencies { 55 | compileOnly("org.jetbrains.compose.runtime:runtime:${org.jetbrains.compose.ComposeBuildConfig.composeVersion}") 56 | 57 | api(libs.solivagant.navigation) 58 | } 59 | } 60 | val commonTest by getting { 61 | dependencies { 62 | implementation(kotlin("test")) 63 | } 64 | } 65 | } 66 | } 67 | 68 | android { 69 | namespace = "com.hoc081098.compose_multiplatform_kmpviewmodel_sample.navigation_shared" 70 | 71 | compileSdk = 72 | libs 73 | .versions 74 | .android 75 | .compile 76 | .map { it.toInt() } 77 | .get() 78 | defaultConfig { 79 | minSdk = 80 | libs 81 | .versions 82 | .android 83 | .min 84 | .map { it.toInt() } 85 | .get() 86 | } 87 | 88 | compileOptions { 89 | sourceCompatibility = 90 | JavaVersion.toVersion( 91 | libs 92 | .versions 93 | .java 94 | .target 95 | .get(), 96 | ) 97 | targetCompatibility = 98 | JavaVersion.toVersion( 99 | libs 100 | .versions 101 | .java 102 | .target 103 | .get(), 104 | ) 105 | } 106 | 107 | buildFeatures { 108 | buildConfig = false 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /core/navigation_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/navigation_shared/appRoutes.common.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.navigation_shared 2 | 3 | import androidx.compose.runtime.Immutable 4 | import com.hoc081098.kmp.viewmodel.parcelable.Parcelize 5 | import com.hoc081098.solivagant.navigation.NavRoot 6 | import com.hoc081098.solivagant.navigation.NavRoute 7 | 8 | @Immutable 9 | @Parcelize 10 | data object SearchPhotoScreenRoute : NavRoot 11 | 12 | @Immutable 13 | @Parcelize 14 | data class PhotoDetailScreenRoute( 15 | val id: String, 16 | ) : NavRoute 17 | -------------------------------------------------------------------------------- /desktopApp/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.compose.desktop.application.dsl.TargetFormat 2 | 3 | plugins { 4 | alias(libs.plugins.kotlin.multiplatform) 5 | alias( 6 | libs 7 | .plugins 8 | .jetbrains 9 | .compose 10 | .mutiplatform, 11 | ) 12 | } 13 | 14 | kotlin { 15 | jvmToolchain { 16 | languageVersion.set( 17 | JavaLanguageVersion.of( 18 | libs 19 | .versions 20 | .java 21 | .toolchain 22 | .get(), 23 | ), 24 | ) 25 | vendor.set(JvmVendorSpec.AZUL) 26 | } 27 | 28 | jvm() 29 | 30 | sourceSets { 31 | val jvmMain by getting { 32 | dependencies { 33 | // Compose 34 | implementation(compose.desktop.currentOs) 35 | implementation(compose.material3) 36 | 37 | // Core 38 | implementation(projects.core.commonShared) 39 | implementation(projects.core.commonUiShared) 40 | implementation(projects.core.navigationShared) 41 | 42 | // Libraries 43 | implementation(projects.libraries.koinUtils) 44 | implementation(projects.libraries.koinComposeUtils) 45 | implementation(projects.libraries.coroutinesUtils) 46 | 47 | // Feature modules 48 | implementation(projects.features.featureSearchPhotoShared) 49 | implementation(projects.features.featurePhotoDetailShared) 50 | 51 | implementation("org.apache.logging.log4j:log4j-api:2.24.1") 52 | implementation("org.apache.logging.log4j:log4j-core:2.24.1") 53 | implementation("org.apache.logging.log4j:log4j-slf4j-impl:2.24.1") 54 | } 55 | } 56 | } 57 | } 58 | 59 | compose.desktop { 60 | application { 61 | mainClass = "com.hoc081098.compose_multiplatform_kmpviewmodel_sample.MainKt" 62 | 63 | nativeDistributions { 64 | targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) 65 | packageName = "KotlinMultiplatformComposeDesktopApplication" 66 | packageVersion = "1.0.0" 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /desktopApp/src/jvmMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/main.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample 2 | 3 | import androidx.compose.runtime.remember 4 | import androidx.compose.ui.window.Window 5 | import androidx.compose.ui.window.application 6 | import androidx.compose.ui.window.rememberWindowState 7 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.common_shared.CommonModule 8 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.common_ui.theme.AppTheme 9 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.koin_compose_utils.koinInjectSetMultibinding 10 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.navigation_shared.SearchPhotoScreenRoute 11 | import com.hoc081098.solivagant.lifecycle.LifecycleOwnerProvider 12 | import com.hoc081098.solivagant.lifecycle.LifecycleRegistry 13 | import com.hoc081098.solivagant.lifecycle.rememberLifecycleOwner 14 | import com.hoc081098.solivagant.navigation.LifecycleControllerEffect 15 | import com.hoc081098.solivagant.navigation.NavDestination 16 | import com.hoc081098.solivagant.navigation.NavHost 17 | import io.github.aakira.napier.DebugAntilog 18 | import io.github.aakira.napier.Napier 19 | import java.util.logging.Level 20 | import java.util.logging.SimpleFormatter 21 | import java.util.logging.StreamHandler 22 | import kotlinx.collections.immutable.toImmutableSet 23 | import org.koin.compose.KoinContext 24 | import org.koin.compose.koinInject 25 | import org.koin.core.context.startKoin 26 | import org.koin.core.logger.Level as KoinLoggerLevel 27 | import org.koin.core.logger.PrintLogger 28 | 29 | fun main() { 30 | Napier.base( 31 | DebugAntilog( 32 | handler = listOf( 33 | StreamHandler(System.out, SimpleFormatter()) 34 | .apply { level = Level.ALL }, 35 | ), 36 | ), 37 | ) 38 | 39 | startKoin { 40 | logger(PrintLogger(level = KoinLoggerLevel.DEBUG)) 41 | 42 | modules( 43 | CommonModule, 44 | NavigationModule, 45 | ) 46 | } 47 | 48 | val lifecycleRegistry = LifecycleRegistry() 49 | 50 | application { 51 | val windowState = rememberWindowState() 52 | 53 | LifecycleControllerEffect( 54 | lifecycleRegistry = lifecycleRegistry, 55 | windowState = windowState, 56 | ) 57 | 58 | Window( 59 | onCloseRequest = ::exitApplication, 60 | title = "KmpViewModel Compose Multiplatform", 61 | state = windowState, 62 | ) { 63 | LifecycleOwnerProvider( 64 | lifecycleOwner = rememberLifecycleOwner(lifecycleRegistry), 65 | ) { 66 | KoinContext { 67 | AppTheme { 68 | NavHost( 69 | startRoute = SearchPhotoScreenRoute, 70 | destinations = koinInjectSetMultibinding(AllDestinationsQualifier) 71 | .let { remember(it) { it.toImmutableSet() } }, 72 | navEventNavigator = koinInject(), 73 | destinationChangedCallback = { route -> 74 | Napier.d(message = "Destination changed: $route", tag = "main") 75 | }, 76 | ) 77 | } 78 | } 79 | } 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /desktopApp/src/jvmMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/navigation.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample 2 | 3 | import androidx.compose.runtime.Stable 4 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.koin_utils.declareSetMultibinding 5 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.koin_utils.intoSetMultibinding 6 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.navigation_shared.PhotoDetailScreenRoute 7 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.navigation_shared.SearchPhotoScreenRoute 8 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.PhotoDetailScreen 9 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.SearchPhotoScreen 10 | import com.hoc081098.solivagant.navigation.NavDestination 11 | import com.hoc081098.solivagant.navigation.NavEventNavigator 12 | import com.hoc081098.solivagant.navigation.ScreenDestination 13 | import org.koin.core.module.dsl.singleOf 14 | import org.koin.core.qualifier.qualifier 15 | import org.koin.dsl.module 16 | 17 | @Stable 18 | @JvmField 19 | val AllDestinationsQualifier = qualifier("AllDestinationsQualifier") 20 | 21 | @JvmField 22 | val NavigationModule = 23 | module { 24 | singleOf(::NavEventNavigator) 25 | 26 | declareSetMultibinding(qualifier = AllDestinationsQualifier) 27 | 28 | intoSetMultibinding( 29 | key = SearchPhotoScreenRoute::class.java, 30 | multibindingQualifier = AllDestinationsQualifier, 31 | ) { 32 | ScreenDestination { route, modifier -> 33 | SearchPhotoScreen(modifier = modifier, route = route) 34 | } 35 | } 36 | 37 | intoSetMultibinding( 38 | key = PhotoDetailScreenRoute::class.java, 39 | multibindingQualifier = AllDestinationsQualifier, 40 | ) { 41 | ScreenDestination { route, modifier -> 42 | PhotoDetailScreen(modifier = modifier, route = route) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /desktopApp/src/jvmMain/resources/log4j.properties: -------------------------------------------------------------------------------- 1 | log4j.appender.CONSOLE.layout.type=PatternLayout 2 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/androidMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/data/PlatformPhotoDetailErrorMapper.android.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.data 2 | 3 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.domain.PhotoDetailError 4 | import java.io.IOException 5 | import java.net.SocketException 6 | import java.net.SocketTimeoutException 7 | import java.net.UnknownHostException 8 | import org.koin.core.annotation.Singleton 9 | 10 | @Singleton 11 | internal actual class PlatformPhotoDetailErrorMapper actual constructor() : (Throwable) -> PhotoDetailError? { 12 | override fun invoke(t: Throwable): PhotoDetailError? = 13 | when (t) { 14 | is PhotoDetailError -> t 15 | is IOException -> 16 | when (t) { 17 | is UnknownHostException, is SocketException -> PhotoDetailError.NetworkError 18 | is SocketTimeoutException -> PhotoDetailError.TimeoutError 19 | else -> null 20 | } 21 | 22 | else -> null 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/androidMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/data/di.android.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.data 2 | 3 | import io.ktor.client.HttpClient 4 | import io.ktor.client.engine.okhttp.OkHttp 5 | import kotlinx.serialization.json.Json 6 | import org.koin.core.annotation.Singleton 7 | 8 | @Singleton 9 | internal actual fun createHttpClient(json: Json): HttpClient = 10 | com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.data.remote.createHttpClient( 11 | engineFactory = OkHttp, 12 | json = json, 13 | ) {} 14 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/androidMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/main.android.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.NonRestartableComposable 5 | import androidx.compose.ui.Modifier 6 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.navigation_shared.PhotoDetailScreenRoute 7 | 8 | @Composable 9 | @NonRestartableComposable 10 | fun PhotoDetailScreen( 11 | route: PhotoDetailScreenRoute, 12 | modifier: Modifier = Modifier, 13 | ) = PhotoDetailScreenWithKoin( 14 | modifier = modifier, 15 | route = route, 16 | ) 17 | 18 | actual fun isDebug(): Boolean = BuildConfig.DEBUG 19 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/data/RealPhotoDetailRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.data 2 | 3 | import arrow.core.Either 4 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.coroutines_utils.AppCoroutineDispatchers 5 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.data.remote.UnsplashApi 6 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.data.remote.response.CoverPhotoResponse 7 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.domain.PhotoCreator 8 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.domain.PhotoDetail 9 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.domain.PhotoDetailRepository 10 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.domain.PhotoSize 11 | import io.github.aakira.napier.Napier 12 | import kotlinx.coroutines.withContext 13 | import org.koin.core.annotation.Singleton 14 | 15 | @Singleton( 16 | binds = [ 17 | PhotoDetailRepository::class, 18 | ], 19 | ) 20 | internal class RealPhotoDetailRepository( 21 | private val unsplashApi: UnsplashApi, 22 | private val photoDetailErrorMapper: PhotoDetailErrorMapper, 23 | private val appCoroutineDispatchers: AppCoroutineDispatchers, 24 | ) : PhotoDetailRepository { 25 | override suspend fun getPhotoDetailById(id: String) = 26 | withContext(appCoroutineDispatchers.io) { 27 | Either 28 | .catch { 29 | unsplashApi 30 | .getPhotoDetailById(id) 31 | .toPhotoDetail() 32 | }.onLeft { 33 | Napier.e( 34 | throwable = it, 35 | tag = "RealPhotoDetailRepository", 36 | message = "getPhotoDetailById($id) failed", 37 | ) 38 | }.mapLeft(photoDetailErrorMapper) 39 | } 40 | } 41 | 42 | private fun CoverPhotoResponse.toPhotoDetail(): PhotoDetail = 43 | PhotoDetail( 44 | id = id, 45 | fullUrl = urls.full, 46 | description = description, 47 | alternativeDescription = altDescription, 48 | createdAt = createdAt, 49 | updatedAt = updatedAt, 50 | promotedAt = promotedAt, 51 | creator = 52 | PhotoCreator( 53 | id = user.id, 54 | username = user.username, 55 | name = user.name, 56 | mediumProfileImageUrl = user.profileImage?.medium, 57 | ), 58 | size = 59 | PhotoSize( 60 | width = width.toUInt(), 61 | height = height.toUInt(), 62 | ), 63 | ) 64 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/data/di.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.data 2 | 3 | import io.ktor.client.HttpClient 4 | import kotlinx.datetime.Instant 5 | import kotlinx.serialization.KSerializer 6 | import kotlinx.serialization.descriptors.PrimitiveKind 7 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor 8 | import kotlinx.serialization.descriptors.SerialDescriptor 9 | import kotlinx.serialization.encoding.Decoder 10 | import kotlinx.serialization.encoding.Encoder 11 | import kotlinx.serialization.json.Json 12 | import kotlinx.serialization.modules.SerializersModule 13 | import org.koin.core.annotation.ComponentScan 14 | import org.koin.core.annotation.Module 15 | import org.koin.core.annotation.Singleton 16 | import org.koin.ksp.generated.module 17 | 18 | @Module 19 | @ComponentScan 20 | internal class DataModule 21 | 22 | @Suppress("NOTHING_TO_INLINE") 23 | internal inline fun dataModule() = DataModule().module 24 | 25 | @Singleton 26 | internal expect fun createHttpClient(json: Json): HttpClient 27 | 28 | @Singleton 29 | internal fun createJson(): Json = 30 | Json { 31 | serializersModule = 32 | SerializersModule { 33 | contextual(Instant::class, InstantSerializer) 34 | } 35 | ignoreUnknownKeys = true 36 | coerceInputValues = true 37 | prettyPrint = true 38 | isLenient = true 39 | encodeDefaults = true 40 | allowSpecialFloatingPointValues = true 41 | allowStructuredMapKeys = true 42 | useArrayPolymorphism = false 43 | } 44 | 45 | internal object InstantSerializer : KSerializer { 46 | override val descriptor: SerialDescriptor = 47 | PrimitiveSerialDescriptor( 48 | "InstantSerializer", 49 | PrimitiveKind.STRING, 50 | ) 51 | 52 | override fun serialize( 53 | encoder: Encoder, 54 | value: Instant, 55 | ) = encoder.encodeString(value.toString()) 56 | 57 | override fun deserialize(decoder: Decoder): Instant = Instant.parse(decoder.decodeString()) 58 | } 59 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/data/errorMapper.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.data 2 | 3 | import arrow.core.nonFatalOrThrow 4 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.domain.PhotoDetailError 5 | import io.github.aakira.napier.Napier 6 | import io.ktor.client.network.sockets.ConnectTimeoutException 7 | import io.ktor.client.network.sockets.SocketTimeoutException 8 | import io.ktor.client.plugins.HttpRequestTimeoutException 9 | import io.ktor.client.plugins.ResponseException 10 | import io.ktor.util.cio.ChannelReadException 11 | import org.koin.core.annotation.Singleton 12 | 13 | internal interface PhotoDetailErrorMapper : (Throwable) -> PhotoDetailError 14 | 15 | @Singleton( 16 | binds = [], 17 | ) 18 | internal expect class PlatformPhotoDetailErrorMapper constructor() : (Throwable) -> PhotoDetailError? 19 | 20 | @Singleton( 21 | binds = [ 22 | PhotoDetailErrorMapper::class, 23 | ], 24 | ) 25 | internal class RealPhotoDetailErrorMapper( 26 | private val platformMapper: PlatformPhotoDetailErrorMapper, 27 | ) : PhotoDetailErrorMapper { 28 | override fun invoke(throwable: Throwable): PhotoDetailError { 29 | Napier.d("PhotoDetailErrorMapperImpl.map $throwable") 30 | 31 | val t = throwable.nonFatalOrThrow() 32 | 33 | // Platform mapper has higher priority. 34 | // If it returns non-null value, then return it, and ignore the rest. 35 | platformMapper(t)?.let { 36 | Napier.d("platformPhotoDetailErrorMapper.map -> $it") 37 | return it 38 | } 39 | 40 | return when (t) { 41 | // Already mapped error 42 | is PhotoDetailError -> t 43 | 44 | // Server error 45 | is ResponseException -> PhotoDetailError.ServerError 46 | 47 | // Timeout error 48 | is HttpRequestTimeoutException, 49 | is ConnectTimeoutException, 50 | is SocketTimeoutException, 51 | -> PhotoDetailError.TimeoutError 52 | 53 | // Network error 54 | is ChannelReadException -> PhotoDetailError.NetworkError 55 | 56 | // Unexpected error 57 | else -> PhotoDetailError.Unexpected 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/data/remote/KtorUnsplashApi.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.data.remote 2 | 3 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.BuildKonfig 4 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.data.remote.response.CoverPhotoResponse 5 | import io.ktor.client.HttpClient 6 | import io.ktor.client.call.body 7 | import io.ktor.client.request.get 8 | import io.ktor.client.request.header 9 | import io.ktor.http.HttpHeaders 10 | import io.ktor.http.URLBuilder 11 | import io.ktor.http.path 12 | import org.koin.core.annotation.Singleton 13 | 14 | @Singleton( 15 | binds = [ 16 | UnsplashApi::class, 17 | ], 18 | ) 19 | internal class KtorUnsplashApi( 20 | private val httpClient: HttpClient, 21 | ) : UnsplashApi { 22 | override suspend fun getPhotoDetailById(id: String) = 23 | httpClient 24 | .get( 25 | URLBuilder(BuildKonfig.UNSPLASH_BASE_URL) 26 | .apply { 27 | path("photos/$id") 28 | }.build(), 29 | ) { 30 | header( 31 | HttpHeaders.Authorization, 32 | "Client-ID ${BuildKonfig.UNSPLASH_CLIENT_ID}", 33 | ) 34 | }.body() 35 | } 36 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/data/remote/UnsplashApi.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.data.remote 2 | 3 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.data.remote.response.CoverPhotoResponse 4 | 5 | internal interface UnsplashApi { 6 | suspend fun getPhotoDetailById(id: String): CoverPhotoResponse 7 | } 8 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/data/remote/createHttpClient.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.data.remote 2 | 3 | import io.github.aakira.napier.Napier 4 | import io.ktor.client.HttpClient 5 | import io.ktor.client.engine.HttpClientEngineConfig 6 | import io.ktor.client.engine.HttpClientEngineFactory 7 | import io.ktor.client.plugins.HttpTimeout 8 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 9 | import io.ktor.client.plugins.logging.LogLevel 10 | import io.ktor.client.plugins.logging.Logger 11 | import io.ktor.client.plugins.logging.Logging 12 | import io.ktor.http.ContentType 13 | import io.ktor.serialization.kotlinx.KotlinxSerializationConverter 14 | import io.ktor.serialization.kotlinx.json.json 15 | import kotlinx.serialization.json.Json 16 | 17 | internal fun createHttpClient( 18 | engineFactory: HttpClientEngineFactory, 19 | json: Json, 20 | block: T.() -> Unit, 21 | ): HttpClient = 22 | HttpClient(engineFactory) { 23 | engine(block) 24 | 25 | install(HttpTimeout) { 26 | requestTimeoutMillis = 15_000 27 | connectTimeoutMillis = 10_000 28 | socketTimeoutMillis = 10_000 29 | } 30 | 31 | install(ContentNegotiation) { 32 | json(json) 33 | register( 34 | ContentType.Text.Plain, 35 | KotlinxSerializationConverter(json), 36 | ) 37 | } 38 | 39 | install(Logging) { 40 | level = LogLevel.ALL 41 | logger = 42 | object : Logger { 43 | override fun log(message: String) { 44 | Napier.d(message = message, tag = "[HttpClient]") 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/data/remote/response/responses.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress( 2 | "ktlint:standard:discouraged-comment-location", 3 | "ktlint:standard:max-line-length", 4 | "ktlint:standard:value-parameter-comment", 5 | ) 6 | 7 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.data.remote.response 8 | 9 | import kotlinx.datetime.Instant 10 | import kotlinx.serialization.SerialName 11 | import kotlinx.serialization.Serializable 12 | 13 | @Serializable 14 | internal data class UrlsResponse( 15 | @SerialName( 16 | value = "raw", 17 | ) val raw: String, // https://images.unsplash.com/photo-1560089000-7433a4ebbd64?ixlib=rb-4.0.3 18 | @SerialName( 19 | value = "full", 20 | ) val full: String, // https://images.unsplash.com/photo-1560089000-7433a4ebbd64?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb 21 | @SerialName( 22 | value = "regular", 23 | ) val regular: String, // https://images.unsplash.com/photo-1560089000-7433a4ebbd64?ixlib=rb-4.0.3&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=1080&fit=max 24 | @SerialName( 25 | value = "small", 26 | ) val small: String, // https://images.unsplash.com/photo-1560089000-7433a4ebbd64?ixlib=rb-4.0.3&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=400&fit=max 27 | @SerialName( 28 | value = "thumb", 29 | ) val thumb: String, // https://images.unsplash.com/photo-1560089000-7433a4ebbd64?ixlib=rb-4.0.3&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=200&fit=max 30 | @SerialName( 31 | value = "small_s3", 32 | ) val smallS3: String, // https://s3.us-west-2.amazonaws.com/images.unsplash.com/small/photo-1560089000-7433a4ebbd64 33 | ) 34 | 35 | @Serializable 36 | internal data class LinksResponse( 37 | @SerialName(value = "self") val self: String, // https://api.unsplash.com/photos/mzt0A967scs 38 | @SerialName(value = "html") val html: String, // https://unsplash.com/photos/mzt0A967scs 39 | @SerialName(value = "download") val download: String, // https://unsplash.com/photos/mzt0A967scs/download 40 | @SerialName( 41 | value = "download_location", 42 | ) val downloadLocation: String, // https://api.unsplash.com/photos/mzt0A967scs/download 43 | ) 44 | 45 | @Serializable 46 | internal data class CoverPhotoResponse( 47 | @SerialName(value = "id") val id: String, // mzt0A967scs 48 | @SerialName(value = "slug") val slug: String, // mzt0A967scs 49 | @SerialName(value = "created_at") val createdAt: Instant, // 2023-03-10T14:13:07Z 50 | @SerialName(value = "updated_at") val updatedAt: Instant, // 2023-04-15T00:20:56Z 51 | @SerialName(value = "promoted_at") val promotedAt: Instant?, // 2022-12-20T11:44:03Z 52 | @SerialName(value = "width") val width: Int, // 4672 53 | @SerialName(value = "height") val height: Int, // 7008 54 | @SerialName(value = "color") val color: String, // #262626 55 | @SerialName(value = "blur_hash") val blurHash: String, // LNBp;G?w%2aJRkt7V@WAOuWZWARO 56 | @SerialName(value = "description") val description: String?, // Tek it married 57 | @SerialName( 58 | value = "alt_description", 59 | ) val altDescription: String?, // a man holding a basketball standing next to a fence 60 | @SerialName(value = "urls") val urls: UrlsResponse, 61 | @SerialName(value = "links") val links: LinksResponse, 62 | @SerialName(value = "likes") val likes: Int, // 2 63 | @SerialName(value = "liked_by_user") val likedByUser: Boolean, // false, 64 | @SerialName(value = "user") val user: UnsplashUserResponse, 65 | ) 66 | 67 | @Serializable 68 | internal data class UnsplashUserResponse( 69 | @SerialName(value = "id") val id: String, 70 | @SerialName(value = "name") val name: String, 71 | @SerialName(value = "username") val username: String, 72 | @SerialName(value = "profile_image") val profileImage: ProfileImage?, 73 | ) { 74 | @Serializable 75 | data class ProfileImage( 76 | @SerialName(value = "small") val small: String?, 77 | @SerialName(value = "medium") val medium: String?, 78 | @SerialName(value = "large") val large: String?, 79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/domain/GetPhotoDetailByIdUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.domain 2 | 3 | import arrow.core.Either 4 | import org.koin.core.annotation.Factory 5 | 6 | @Factory 7 | class GetPhotoDetailByIdUseCase( 8 | private val repository: PhotoDetailRepository, 9 | ) { 10 | suspend operator fun invoke(id: String): Either = repository.getPhotoDetailById(id) 11 | } 12 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/domain/PhotoDetail.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.domain 2 | 3 | import androidx.compose.runtime.Immutable 4 | import kotlinx.datetime.Instant 5 | 6 | @Immutable 7 | data class PhotoDetail( 8 | val id: String, 9 | val fullUrl: String, 10 | val description: String?, 11 | val alternativeDescription: String?, 12 | val createdAt: Instant, 13 | val updatedAt: Instant, 14 | val promotedAt: Instant?, 15 | val creator: PhotoCreator, 16 | val size: PhotoSize, 17 | ) 18 | 19 | @Immutable 20 | data class PhotoSize( 21 | val width: UInt, 22 | val height: UInt, 23 | ) 24 | 25 | @Immutable 26 | data class PhotoCreator( 27 | val id: String, 28 | val username: String, 29 | val name: String, 30 | val mediumProfileImageUrl: String?, 31 | ) 32 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/domain/PhotoDetailError.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.domain 2 | 3 | import androidx.compose.runtime.Immutable 4 | 5 | @Immutable 6 | sealed interface PhotoDetailError { 7 | data object NetworkError : PhotoDetailError 8 | 9 | data object TimeoutError : PhotoDetailError 10 | 11 | data object ServerError : PhotoDetailError 12 | 13 | data object Unexpected : PhotoDetailError 14 | } 15 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/domain/PhotoDetailRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.domain 2 | 3 | import arrow.core.Either 4 | 5 | interface PhotoDetailRepository { 6 | suspend fun getPhotoDetailById(id: String): Either 7 | } 8 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/domain/di.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.domain 2 | 3 | import org.koin.core.annotation.ComponentScan 4 | import org.koin.core.annotation.Module 5 | import org.koin.ksp.generated.module 6 | 7 | @Module 8 | @ComponentScan 9 | internal class DomainModule 10 | 11 | @Suppress("NOTHING_TO_INLINE") 12 | internal inline fun domainModule() = DomainModule().module 13 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/main.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.Immutable 5 | import androidx.compose.runtime.SideEffect 6 | import androidx.compose.runtime.Stable 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.ui.Modifier 9 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.koin_compose_utils.rememberKoinModulesOnRoute 10 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.navigation_shared.PhotoDetailScreenRoute 11 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.data.dataModule 12 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.domain.domainModule 13 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.presentation.PhotoDetailScreen 14 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.presentation.presentationModule 15 | import io.github.aakira.napier.Napier 16 | import kotlin.jvm.JvmField 17 | import org.koin.dsl.module 18 | 19 | @JvmField 20 | internal val FeaturePhotoDetailModule = 21 | module { 22 | includes( 23 | dataModule(), 24 | domainModule(), 25 | presentationModule(), 26 | ) 27 | } 28 | 29 | @Composable 30 | internal fun PhotoDetailScreenWithKoin( 31 | route: PhotoDetailScreenRoute, 32 | modifier: Modifier = Modifier, 33 | ) { 34 | val loaded by rememberKoinModulesOnRoute( 35 | route = route, 36 | unloadModules = true, 37 | ) { listOf(FeaturePhotoDetailModule) } 38 | 39 | if (loaded) { 40 | PhotoDetailScreen( 41 | modifier = modifier, 42 | route = route, 43 | ) 44 | } else { 45 | SideEffect { 46 | Napier.d( 47 | message = "PhotoDetailScreenWithKoin: unloaded", 48 | tag = "PhotoDetailScreenWithKoin", 49 | ) 50 | } 51 | } 52 | } 53 | 54 | @Immutable 55 | enum class BuildFlavor { 56 | DEV, 57 | PROD, 58 | ; 59 | 60 | companion object { 61 | @Stable 62 | val Current: BuildFlavor by lazy { 63 | when (BuildKonfig.FLAVOR) { 64 | "dev" -> DEV 65 | "prod" -> PROD 66 | else -> error("Unknown flavor ${BuildKonfig.FLAVOR}") 67 | } 68 | } 69 | } 70 | } 71 | 72 | expect fun isDebug(): Boolean 73 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/presentation/PhotoDetailPartialStateChange.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.presentation 2 | 3 | import androidx.compose.runtime.Immutable 4 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.domain.PhotoDetailError 5 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.presentation.PhotoDetailUiState.PhotoDetailUi 6 | 7 | @Immutable 8 | internal sealed interface PhotoDetailPartialStateChange { 9 | fun reduce(state: PhotoDetailUiState): PhotoDetailUiState 10 | 11 | sealed interface InitAndRetry : PhotoDetailPartialStateChange { 12 | data object Loading : InitAndRetry { 13 | override fun reduce(state: PhotoDetailUiState) = PhotoDetailUiState.Loading 14 | } 15 | 16 | data class Error( 17 | val error: PhotoDetailError, 18 | ) : InitAndRetry { 19 | override fun reduce(state: PhotoDetailUiState) = PhotoDetailUiState.Error(error) 20 | } 21 | 22 | data class Content( 23 | val photoDetail: PhotoDetailUi, 24 | ) : InitAndRetry { 25 | override fun reduce(state: PhotoDetailUiState) = 26 | if (state is PhotoDetailUiState.Content) { 27 | state.copy(photoDetail = photoDetail) 28 | } else { 29 | PhotoDetailUiState.Content(photoDetail = photoDetail, isRefreshing = false) 30 | } 31 | } 32 | } 33 | 34 | sealed interface Refresh : PhotoDetailPartialStateChange { 35 | data object Refreshing : Refresh { 36 | override fun reduce(state: PhotoDetailUiState) = 37 | if (state is PhotoDetailUiState.Content) { 38 | state.copy(isRefreshing = true) 39 | } else { 40 | state 41 | } 42 | } 43 | 44 | data class Error( 45 | val error: PhotoDetailError, 46 | ) : Refresh { 47 | override fun reduce(state: PhotoDetailUiState) = 48 | if (state is PhotoDetailUiState.Content) { 49 | state.copy(isRefreshing = false) 50 | } else { 51 | state 52 | } 53 | } 54 | 55 | data class Content( 56 | val photoDetail: PhotoDetailUi, 57 | ) : Refresh { 58 | override fun reduce(state: PhotoDetailUiState) = 59 | PhotoDetailUiState.Content( 60 | photoDetail = photoDetail, 61 | isRefreshing = false, 62 | ) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/presentation/PhotoDetailScreen.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.presentation 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.ExperimentalLayoutApi 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.consumeWindowInsets 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.height 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.rememberScrollState 13 | import androidx.compose.foundation.verticalScroll 14 | import androidx.compose.material.icons.Icons 15 | import androidx.compose.material.icons.filled.ArrowBack 16 | import androidx.compose.material3.CenterAlignedTopAppBar 17 | import androidx.compose.material3.ExperimentalMaterial3Api 18 | import androidx.compose.material3.Icon 19 | import androidx.compose.material3.IconButton 20 | import androidx.compose.material3.MaterialTheme 21 | import androidx.compose.material3.Scaffold 22 | import androidx.compose.material3.Text 23 | import androidx.compose.runtime.Composable 24 | import androidx.compose.runtime.DisposableEffect 25 | import androidx.compose.runtime.getValue 26 | import androidx.compose.runtime.remember 27 | import androidx.compose.ui.Modifier 28 | import androidx.compose.ui.unit.dp 29 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.common_ui.components.ErrorMessageAndRetryButton 30 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.common_ui.components.LoadingIndicator 31 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.navigation_shared.PhotoDetailScreenRoute 32 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.domain.PhotoDetailError 33 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.presentation.components.CreatorInfoCard 34 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.presentation.components.LargePhotoImage 35 | import com.hoc081098.kmp.viewmodel.koin.compose.koinKmpViewModel 36 | import com.hoc081098.solivagant.lifecycle.compose.collectAsStateWithLifecycle 37 | 38 | @Composable 39 | internal fun PhotoDetailScreen( 40 | route: PhotoDetailScreenRoute, 41 | modifier: Modifier = Modifier, 42 | viewModel: PhotoDetailViewModel = koinKmpViewModel( 43 | key = "${PhotoDetailViewModel::class.simpleName}_$route", 44 | ), 45 | ) { 46 | val uiState by viewModel.uiStateFlow.collectAsStateWithLifecycle() 47 | 48 | @Suppress("SuspiciousCallableReferenceInLambda") 49 | val processIntent: (PhotoDetailViewIntent) -> Unit = remember(viewModel) { viewModel::process } 50 | 51 | DisposableEffect(processIntent) { 52 | processIntent(PhotoDetailViewIntent.Init) 53 | onDispose {} 54 | } 55 | 56 | PhotoDetailContent( 57 | modifier = modifier, 58 | route = route, 59 | uiState = uiState, 60 | onRetry = { processIntent(PhotoDetailViewIntent.Retry) }, 61 | onNavigationBack = { processIntent(PhotoDetailViewIntent.NavigateBack) }, 62 | ) 63 | } 64 | 65 | @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) 66 | @Composable 67 | private fun PhotoDetailContent( 68 | route: PhotoDetailScreenRoute, 69 | uiState: PhotoDetailUiState, 70 | onRetry: () -> Unit, 71 | onNavigationBack: () -> Unit, 72 | modifier: Modifier = Modifier, 73 | ) { 74 | Scaffold( 75 | topBar = { 76 | CenterAlignedTopAppBar( 77 | title = { Text(text = "Photo detail") }, 78 | navigationIcon = { 79 | IconButton(onClick = onNavigationBack) { 80 | Icon( 81 | imageVector = Icons.Default.ArrowBack, 82 | contentDescription = "Back", 83 | ) 84 | } 85 | }, 86 | ) 87 | }, 88 | ) { padding -> 89 | Box( 90 | modifier = 91 | modifier 92 | .fillMaxSize() 93 | .padding(padding) 94 | .consumeWindowInsets(padding), 95 | ) { 96 | when (uiState) { 97 | PhotoDetailUiState.Loading -> { 98 | LoadingIndicator( 99 | modifier = Modifier.matchParentSize(), 100 | ) 101 | } 102 | 103 | is PhotoDetailUiState.Error -> { 104 | ErrorMessageAndRetryButton( 105 | modifier = Modifier.matchParentSize(), 106 | onRetry = onRetry, 107 | errorMessage = 108 | when (uiState.error) { 109 | PhotoDetailError.NetworkError -> "Network error" 110 | PhotoDetailError.ServerError -> "Server error" 111 | PhotoDetailError.TimeoutError -> "Timeout error" 112 | PhotoDetailError.Unexpected -> "Unexpected error" 113 | }, 114 | ) 115 | } 116 | 117 | is PhotoDetailUiState.Content -> { 118 | val detail = uiState.photoDetail 119 | 120 | Column( 121 | modifier = 122 | Modifier 123 | .matchParentSize() 124 | .padding(horizontal = 16.dp) 125 | .verticalScroll(rememberScrollState()), 126 | ) { 127 | Spacer(modifier = Modifier.height(16.dp)) 128 | 129 | CreatorInfoCard( 130 | modifier = Modifier.fillMaxWidth(), 131 | creator = detail.creator, 132 | ) 133 | 134 | Spacer(modifier = Modifier.height(16.dp)) 135 | 136 | Text( 137 | text = "Size: ${detail.size.width} x ${detail.size.height}", 138 | style = MaterialTheme.typography.bodyMedium, 139 | ) 140 | 141 | Spacer(modifier = Modifier.height(4.dp)) 142 | 143 | LargePhotoImage( 144 | modifier = Modifier.fillMaxWidth(), 145 | route = route, 146 | url = detail.fullUrl, 147 | contentDescription = detail.description, 148 | size = detail.size, 149 | ) 150 | 151 | Spacer(modifier = Modifier.height(16.dp)) 152 | } 153 | } 154 | } 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/presentation/PhotoDetailUiState.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.presentation 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.compose.runtime.Stable 5 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.domain.PhotoDetail 6 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.domain.PhotoDetailError 7 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.presentation.PhotoDetailUiState.PhotoCreatorUi 8 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.presentation.PhotoDetailUiState.PhotoDetailUi 9 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.presentation.PhotoDetailUiState.PhotoSizeUi 10 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.stable_wrappers.ImmutableWrapper 11 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.stable_wrappers.toImmutableWrapper 12 | import kotlinx.datetime.Instant 13 | 14 | @Immutable 15 | internal sealed interface PhotoDetailUiState { 16 | data object Loading : PhotoDetailUiState 17 | 18 | data class Content( 19 | val photoDetail: PhotoDetailUi, 20 | val isRefreshing: Boolean, 21 | ) : PhotoDetailUiState 22 | 23 | data class Error( 24 | val error: PhotoDetailError, 25 | ) : PhotoDetailUiState 26 | 27 | @Immutable 28 | data class PhotoDetailUi( 29 | val id: String, 30 | val fullUrl: String, 31 | val description: String?, 32 | val alternativeDescription: String?, 33 | val createdAt: ImmutableWrapper, 34 | val updatedAt: ImmutableWrapper, 35 | val promotedAt: ImmutableWrapper, 36 | val creator: PhotoCreatorUi, 37 | val size: PhotoSizeUi, 38 | ) 39 | 40 | @Immutable 41 | data class PhotoSizeUi( 42 | val width: UInt, 43 | val height: UInt, 44 | ) 45 | 46 | @Immutable 47 | data class PhotoCreatorUi( 48 | val id: String, 49 | val username: String, 50 | val name: String, 51 | val mediumProfileImageUrl: String?, 52 | ) 53 | 54 | companion object { 55 | val INITIAL: PhotoDetailUiState get() = Loading 56 | } 57 | } 58 | 59 | @Stable 60 | internal fun PhotoDetail.toPhotoDetailUi(): PhotoDetailUi = 61 | PhotoDetailUi( 62 | id = id, 63 | fullUrl = fullUrl, 64 | description = description, 65 | alternativeDescription = alternativeDescription, 66 | createdAt = createdAt.toImmutableWrapper(), 67 | updatedAt = updatedAt.toImmutableWrapper(), 68 | promotedAt = promotedAt.toImmutableWrapper(), 69 | creator = 70 | PhotoCreatorUi( 71 | id = creator.id, 72 | username = creator.username, 73 | name = creator.name, 74 | mediumProfileImageUrl = creator.mediumProfileImageUrl, 75 | ), 76 | size = 77 | PhotoSizeUi( 78 | width = size.width, 79 | height = size.height, 80 | ), 81 | ) 82 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/presentation/PhotoDetailViewIntent.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.presentation 2 | 3 | import androidx.compose.runtime.Immutable 4 | 5 | @Immutable 6 | internal sealed interface PhotoDetailViewIntent { 7 | data object Init : PhotoDetailViewIntent 8 | 9 | data object Retry : PhotoDetailViewIntent 10 | 11 | data object Refresh : PhotoDetailViewIntent 12 | 13 | data object NavigateBack : PhotoDetailViewIntent 14 | } 15 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/presentation/PhotoDetailViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.presentation 2 | 3 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.coroutines_utils.publish 4 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.navigation_shared.PhotoDetailScreenRoute 5 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.domain.GetPhotoDetailByIdUseCase 6 | import com.hoc081098.flowext.flatMapFirst 7 | import com.hoc081098.flowext.flowFromSuspend 8 | import com.hoc081098.flowext.ignoreElements 9 | import com.hoc081098.flowext.startWith 10 | import com.hoc081098.kmp.viewmodel.SavedStateHandle 11 | import com.hoc081098.kmp.viewmodel.ViewModel 12 | import com.hoc081098.solivagant.navigation.NavEventNavigator 13 | import com.hoc081098.solivagant.navigation.requireRoute 14 | import io.github.aakira.napier.Napier 15 | import kotlin.jvm.JvmName 16 | import kotlinx.coroutines.ExperimentalCoroutinesApi 17 | import kotlinx.coroutines.channels.Channel 18 | import kotlinx.coroutines.flow.Flow 19 | import kotlinx.coroutines.flow.SharingStarted 20 | import kotlinx.coroutines.flow.StateFlow 21 | import kotlinx.coroutines.flow.consumeAsFlow 22 | import kotlinx.coroutines.flow.filter 23 | import kotlinx.coroutines.flow.filterIsInstance 24 | import kotlinx.coroutines.flow.flatMapConcat 25 | import kotlinx.coroutines.flow.map 26 | import kotlinx.coroutines.flow.merge 27 | import kotlinx.coroutines.flow.onEach 28 | import kotlinx.coroutines.flow.scan 29 | import kotlinx.coroutines.flow.stateIn 30 | import kotlinx.coroutines.flow.take 31 | import kotlinx.coroutines.launch 32 | import org.koin.core.annotation.Factory 33 | 34 | @OptIn(ExperimentalCoroutinesApi::class) 35 | @Factory 36 | internal class PhotoDetailViewModel( 37 | savedStateHandle: SavedStateHandle, 38 | private val getPhotoDetailByIdUseCase: GetPhotoDetailByIdUseCase, 39 | private val navigator: NavEventNavigator, 40 | ) : ViewModel() { 41 | private val route = savedStateHandle.requireRoute() 42 | 43 | private val _intentChannel = Channel(capacity = 1) 44 | 45 | internal val uiStateFlow: StateFlow 46 | 47 | init { 48 | Napier.d("init route=$route -> $this", tag = "PhotoDetailViewModel") 49 | addCloseable { Napier.d("close route=$route -> $this", tag = "PhotoDetailViewModel") } 50 | 51 | uiStateFlow = 52 | _intentChannel 53 | .consumeAsFlow() 54 | .onEach { Napier.d(message = "intent $it", tag = "PhotoDetailViewModel") } 55 | .publish { 56 | merge( 57 | select { 58 | it 59 | .filterIsInstance() 60 | .toPartialStateChangesFlow() 61 | }, 62 | select { 63 | it 64 | .filterIsInstance() 65 | .toPartialStateChangesFlow() 66 | }, 67 | select { 68 | it 69 | .filterIsInstance() 70 | .toPartialStateChangesFlow() 71 | }, 72 | select { 73 | it 74 | .filterIsInstance() 75 | .onEach { navigator.navigateBack() } 76 | .ignoreElements() 77 | }, 78 | ) 79 | }.scan(PhotoDetailUiState.INITIAL) { state, change -> change.reduce(state) } 80 | .stateIn( 81 | scope = viewModelScope, 82 | started = SharingStarted.Eagerly, 83 | initialValue = PhotoDetailUiState.INITIAL, 84 | ) 85 | } 86 | 87 | internal fun process(intent: PhotoDetailViewIntent) { 88 | viewModelScope.launch { _intentChannel.send(intent) } 89 | } 90 | 91 | //region View intent processors 92 | @JvmName("initIntentFlowToPartialStateChangesFlow") 93 | private fun Flow.toPartialStateChangesFlow(): 94 | Flow = 95 | take(1) 96 | .flatMapConcat { 97 | flowFromSuspend { getPhotoDetailByIdUseCase(route.id) } 98 | .map { either -> 99 | either.fold( 100 | ifLeft = { 101 | PhotoDetailPartialStateChange.InitAndRetry.Error(it) 102 | }, 103 | ifRight = { 104 | PhotoDetailPartialStateChange.InitAndRetry.Content(it.toPhotoDetailUi()) 105 | }, 106 | ) 107 | }.startWith(PhotoDetailPartialStateChange.InitAndRetry.Loading) 108 | } 109 | 110 | @JvmName("retryIntentFlowToPartialStateChangesFlow") 111 | private fun Flow.toPartialStateChangesFlow(): 112 | Flow = 113 | filter { uiStateFlow.value is PhotoDetailUiState.Error } 114 | .flatMapFirst { 115 | flowFromSuspend { getPhotoDetailByIdUseCase(route.id) } 116 | .map { either -> 117 | either.fold( 118 | ifLeft = { 119 | PhotoDetailPartialStateChange.InitAndRetry.Error(it) 120 | }, 121 | ifRight = { 122 | PhotoDetailPartialStateChange.InitAndRetry.Content(it.toPhotoDetailUi()) 123 | }, 124 | ) 125 | }.startWith(PhotoDetailPartialStateChange.InitAndRetry.Loading) 126 | } 127 | 128 | @JvmName("refreshIntentFlowToPartialStateChangesFlow") 129 | private fun Flow.toPartialStateChangesFlow(): 130 | Flow = 131 | filter { 132 | val state = uiStateFlow.value 133 | state is PhotoDetailUiState.Content && !state.isRefreshing 134 | }.flatMapFirst { 135 | flowFromSuspend { getPhotoDetailByIdUseCase(route.id) } 136 | .map { either -> 137 | either.fold( 138 | ifLeft = { 139 | PhotoDetailPartialStateChange.Refresh.Error(it) 140 | }, 141 | ifRight = { 142 | PhotoDetailPartialStateChange.Refresh.Content(it.toPhotoDetailUi()) 143 | }, 144 | ) 145 | }.startWith(PhotoDetailPartialStateChange.Refresh.Refreshing) 146 | } 147 | //endregion 148 | } 149 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/presentation/components/CreatorInfoCard.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.presentation.components 2 | 3 | import androidx.compose.animation.core.tween 4 | import androidx.compose.foundation.ExperimentalFoundationApi 5 | import androidx.compose.foundation.basicMarquee 6 | import androidx.compose.foundation.layout.Arrangement 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.Row 10 | import androidx.compose.foundation.layout.Spacer 11 | import androidx.compose.foundation.layout.fillMaxWidth 12 | import androidx.compose.foundation.layout.height 13 | import androidx.compose.foundation.layout.padding 14 | import androidx.compose.foundation.layout.size 15 | import androidx.compose.foundation.layout.width 16 | import androidx.compose.foundation.shape.CircleShape 17 | import androidx.compose.foundation.shape.RoundedCornerShape 18 | import androidx.compose.material.icons.Icons 19 | import androidx.compose.material.icons.filled.Person 20 | import androidx.compose.material3.ElevatedCard 21 | import androidx.compose.material3.Icon 22 | import androidx.compose.material3.MaterialTheme 23 | import androidx.compose.material3.Text 24 | import androidx.compose.runtime.Composable 25 | import androidx.compose.ui.Alignment 26 | import androidx.compose.ui.Modifier 27 | import androidx.compose.ui.draw.clip 28 | import androidx.compose.ui.layout.ContentScale 29 | import androidx.compose.ui.text.style.TextOverflow 30 | import androidx.compose.ui.unit.dp 31 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.common_ui.components.LoadingIndicator 32 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.presentation.PhotoDetailUiState 33 | import io.kamel.image.KamelImage 34 | import io.kamel.image.asyncPainterResource 35 | import org.jetbrains.compose.resources.ExperimentalResourceApi 36 | import org.jetbrains.compose.resources.painterResource 37 | 38 | @OptIn(ExperimentalFoundationApi::class, ExperimentalResourceApi::class) 39 | @Composable 40 | internal fun CreatorInfoCard( 41 | creator: PhotoDetailUiState.PhotoCreatorUi, 42 | modifier: Modifier = Modifier, 43 | ) { 44 | ElevatedCard( 45 | modifier = modifier, 46 | shape = RoundedCornerShape(10.dp), 47 | ) { 48 | Row( 49 | modifier = 50 | Modifier 51 | .fillMaxWidth() 52 | .padding(16.dp), 53 | verticalAlignment = Alignment.CenterVertically, 54 | ) { 55 | if (creator.mediumProfileImageUrl != null) { 56 | KamelImage( 57 | modifier = 58 | Modifier 59 | .size(65.dp) 60 | .clip(CircleShape), 61 | resource = asyncPainterResource(data = creator.mediumProfileImageUrl), 62 | contentDescription = null, 63 | contentScale = ContentScale.Crop, 64 | animationSpec = tween(), 65 | onLoading = { 66 | LoadingIndicator( 67 | modifier = Modifier.matchParentSize(), 68 | ) 69 | }, 70 | onFailure = { 71 | Icon( 72 | modifier = Modifier.align(Alignment.Center), 73 | imageVector = Icons.Default.Person, 74 | contentDescription = null, 75 | ) 76 | }, 77 | ) 78 | } else { 79 | Box( 80 | modifier = Modifier.size(65.dp), 81 | contentAlignment = Alignment.Center, 82 | ) { 83 | Icon( 84 | modifier = Modifier.align(Alignment.Center), 85 | imageVector = Icons.Default.Person, 86 | contentDescription = null, 87 | ) 88 | } 89 | } 90 | 91 | Spacer(modifier = Modifier.width(8.dp)) 92 | 93 | Column( 94 | modifier = Modifier.weight(1f), 95 | verticalArrangement = Arrangement.Top, 96 | ) { 97 | Text( 98 | modifier = 99 | Modifier 100 | .fillMaxWidth() 101 | .basicMarquee(), 102 | maxLines = 1, 103 | overflow = TextOverflow.Ellipsis, 104 | text = creator.name, 105 | style = MaterialTheme.typography.titleLarge, 106 | ) 107 | 108 | Spacer(modifier = Modifier.height(6.dp)) 109 | 110 | Row( 111 | horizontalArrangement = Arrangement.Start, 112 | verticalAlignment = Alignment.CenterVertically, 113 | ) { 114 | Icon( 115 | painter = painterResource("ic_unsplash_svg.xml"), 116 | contentDescription = null, 117 | ) 118 | 119 | Text( 120 | text = "@${creator.username}", 121 | style = MaterialTheme.typography.bodyMedium, 122 | ) 123 | } 124 | } 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/presentation/components/LargePhotoImage.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.presentation.components 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Spacer 5 | import androidx.compose.foundation.layout.aspectRatio 6 | import androidx.compose.foundation.layout.height 7 | import androidx.compose.foundation.shape.RoundedCornerShape 8 | import androidx.compose.material.icons.Icons 9 | import androidx.compose.material.icons.filled.Person 10 | import androidx.compose.material3.CircularProgressIndicator 11 | import androidx.compose.material3.Icon 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.material3.Text 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.CompositionLocalProvider 16 | import androidx.compose.runtime.getValue 17 | import androidx.compose.runtime.rememberUpdatedState 18 | import androidx.compose.ui.Alignment 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.draw.clip 21 | import androidx.compose.ui.layout.ContentScale 22 | import androidx.compose.ui.text.style.TextAlign 23 | import androidx.compose.ui.unit.dp 24 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.navigation_shared.PhotoDetailScreenRoute 25 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.presentation.PhotoDetailUiState.PhotoSizeUi 26 | import com.hoc081098.kmp.viewmodel.Closeable 27 | import com.hoc081098.solivagant.navigation.rememberCloseableOnRoute 28 | import io.github.aakira.napier.Napier 29 | import io.kamel.core.config.KamelConfig 30 | import io.kamel.core.config.takeFrom 31 | import io.kamel.image.KamelImage 32 | import io.kamel.image.asyncPainterResource 33 | import io.kamel.image.config.LocalKamelConfig 34 | import kotlin.jvm.JvmField 35 | import kotlin.math.roundToInt 36 | 37 | private class KamelConfigWrapperCloseable( 38 | @JvmField val kamelConfig: KamelConfig, 39 | ) : Closeable { 40 | override fun close() { 41 | kamelConfig.runCatching { imageVectorCache.clear() } 42 | Napier.d(message = "Clear imageVectorCache") 43 | } 44 | } 45 | 46 | @Composable 47 | internal fun LargePhotoImage( 48 | route: PhotoDetailScreenRoute, 49 | url: String, 50 | contentDescription: String?, 51 | size: PhotoSizeUi, 52 | modifier: Modifier = Modifier, 53 | ) { 54 | val currentKamelConfig by rememberUpdatedState(LocalKamelConfig.current) 55 | 56 | val kamelConfigWrapper = 57 | rememberCloseableOnRoute(route) { 58 | KamelConfigWrapperCloseable( 59 | KamelConfig { 60 | takeFrom(currentKamelConfig) 61 | 62 | // Cache only 1 image 63 | imageBitmapCacheSize = 1 64 | 65 | // Disable cache 66 | imageVectorCacheSize = 0 67 | svgCacheSize = 0 68 | }, 69 | ) 70 | } 71 | 72 | CompositionLocalProvider(LocalKamelConfig provides kamelConfigWrapper.kamelConfig) { 73 | KamelImage( 74 | modifier = modifier 75 | .aspectRatio(size.width.toFloat() / size.height.toFloat()) 76 | .clip(RoundedCornerShape(size = 8.dp)), 77 | resource = asyncPainterResource(data = url), 78 | contentDescription = contentDescription, 79 | contentScale = ContentScale.Crop, 80 | onLoading = { 81 | Column( 82 | modifier = Modifier.align(Alignment.Center), 83 | horizontalAlignment = Alignment.CenterHorizontally, 84 | ) { 85 | CircularProgressIndicator( 86 | progress = it, 87 | ) 88 | 89 | Spacer(modifier = Modifier.height(8.dp)) 90 | 91 | Text( 92 | text = "${(it * 100).roundToInt()} %", 93 | style = MaterialTheme.typography.labelSmall, 94 | textAlign = TextAlign.Center, 95 | ) 96 | } 97 | }, 98 | onFailure = { 99 | Icon( 100 | modifier = Modifier.align(Alignment.Center), 101 | imageVector = Icons.Default.Person, 102 | contentDescription = null, 103 | ) 104 | }, 105 | ) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/presentation/di.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.presentation 2 | 3 | import org.koin.core.annotation.ComponentScan 4 | import org.koin.core.annotation.Module 5 | import org.koin.ksp.generated.module 6 | 7 | @Module 8 | @ComponentScan 9 | internal class PresentationModule 10 | 11 | @Suppress("NOTHING_TO_INLINE") 12 | internal inline fun presentationModule() = PresentationModule().module 13 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/commonMain/resources/ic_unsplash_svg.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/desktopMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/data/PlatformPhotoDetailErrorMapper.desktop.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.data 2 | 3 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.domain.PhotoDetailError 4 | import java.io.IOException 5 | import java.net.SocketException 6 | import java.net.SocketTimeoutException 7 | import java.net.UnknownHostException 8 | import org.koin.core.annotation.Singleton 9 | 10 | @Singleton 11 | internal actual class PlatformPhotoDetailErrorMapper actual constructor() : (Throwable) -> PhotoDetailError? { 12 | override fun invoke(t: Throwable): PhotoDetailError? = 13 | when (t) { 14 | is PhotoDetailError -> t 15 | is IOException -> 16 | when (t) { 17 | is UnknownHostException, is SocketException -> PhotoDetailError.NetworkError 18 | is SocketTimeoutException -> PhotoDetailError.TimeoutError 19 | else -> null 20 | } 21 | 22 | else -> null 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/desktopMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/data/di.desktop.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.data 2 | 3 | import io.ktor.client.HttpClient 4 | import io.ktor.client.engine.java.Java 5 | import kotlinx.serialization.json.Json 6 | import org.koin.core.annotation.Singleton 7 | 8 | @Singleton 9 | internal actual fun createHttpClient(json: Json): HttpClient = 10 | com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.data.remote.createHttpClient( 11 | engineFactory = Java, 12 | json = json, 13 | ) {} 14 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/desktopMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/main.desktop.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.Modifier 5 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.navigation_shared.PhotoDetailScreenRoute 6 | 7 | @Composable 8 | fun PhotoDetailScreen( 9 | route: PhotoDetailScreenRoute, 10 | modifier: Modifier = Modifier, 11 | ) { 12 | PhotoDetailScreenWithKoin( 13 | modifier = modifier, 14 | route = route, 15 | ) 16 | } 17 | 18 | actual fun isDebug(): Boolean = true 19 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/iosMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/data/PlatformPhotoDetailErrorMapper.ios.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.data 2 | 3 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.domain.PhotoDetailError 4 | import io.ktor.client.engine.darwin.DarwinHttpRequestException 5 | import io.ktor.client.network.sockets.SocketTimeoutException 6 | import org.koin.core.annotation.Singleton 7 | import platform.Foundation.NSURLErrorDomain 8 | import platform.Foundation.NSURLErrorNetworkConnectionLost 9 | import platform.Foundation.NSURLErrorNotConnectedToInternet 10 | 11 | @Singleton 12 | internal actual class PlatformPhotoDetailErrorMapper actual constructor() : (Throwable) -> PhotoDetailError? { 13 | override fun invoke(t: Throwable): PhotoDetailError? = 14 | when (t) { 15 | is PhotoDetailError -> t 16 | is SocketTimeoutException -> PhotoDetailError.TimeoutError 17 | is DarwinHttpRequestException -> 18 | when { 19 | t.origin.domain == NSURLErrorDomain && t.origin.code in NETWORK_ERROR_CODES -> 20 | PhotoDetailError.NetworkError 21 | 22 | else -> null 23 | } 24 | 25 | else -> null 26 | } 27 | 28 | private companion object { 29 | private val NETWORK_ERROR_CODES = 30 | setOf( 31 | NSURLErrorNotConnectedToInternet, 32 | NSURLErrorNetworkConnectionLost, 33 | ) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/iosMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/data/di.ios.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.data 2 | 3 | import io.ktor.client.HttpClient 4 | import io.ktor.client.engine.darwin.Darwin 5 | import kotlinx.serialization.json.Json 6 | import org.koin.core.annotation.Singleton 7 | 8 | @Singleton 9 | internal actual fun createHttpClient(json: Json): HttpClient = 10 | com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail.data.remote.createHttpClient( 11 | engineFactory = Darwin, 12 | json = json, 13 | ) {} 14 | -------------------------------------------------------------------------------- /features/feature_photo_detail_shared/src/iosMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/photo_detail/main.ios.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.photo_detail 2 | 3 | import androidx.compose.ui.window.ComposeUIViewController 4 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.navigation_shared.PhotoDetailScreenRoute 5 | import kotlin.experimental.ExperimentalNativeApi 6 | import platform.UIKit.UIViewController 7 | 8 | fun PhotoDetailViewController(route: PhotoDetailScreenRoute): UIViewController = 9 | ComposeUIViewController { 10 | PhotoDetailScreenWithKoin( 11 | route = route, 12 | ) 13 | } 14 | 15 | @OptIn(ExperimentalNativeApi::class) 16 | actual fun isDebug(): Boolean = Platform.isDebugBinary 17 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.codingfeline.buildkonfig.compiler.FieldSpec 2 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.isCiBuild 3 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.readPropertiesFile 4 | 5 | plugins { 6 | alias(libs.plugins.kotlin.multiplatform) 7 | alias(libs.plugins.android.library) 8 | alias( 9 | libs 10 | .plugins 11 | .jetbrains 12 | .compose 13 | .mutiplatform, 14 | ) 15 | alias(libs.plugins.kotlin.serialization) 16 | alias(libs.plugins.buildkonfig) 17 | alias(libs.plugins.ksp) 18 | id("compose_multiplatform_kmpviewmodel_sample.empty") 19 | } 20 | 21 | kotlin { 22 | jvmToolchain { 23 | languageVersion.set( 24 | JavaLanguageVersion.of( 25 | libs 26 | .versions 27 | .java 28 | .toolchain 29 | .get(), 30 | ), 31 | ) 32 | vendor.set(JvmVendorSpec.AZUL) 33 | } 34 | 35 | androidTarget() 36 | 37 | jvm("desktop") 38 | 39 | iosX64() 40 | iosArm64() 41 | iosSimulatorArm64() 42 | 43 | applyDefaultHierarchyTemplate() 44 | 45 | sourceSets { 46 | commonMain { 47 | kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin") 48 | 49 | dependencies { 50 | // Compose 51 | api(compose.runtime) 52 | api(compose.foundation) 53 | api(compose.material3) 54 | @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) 55 | implementation(compose.components.resources) 56 | 57 | // Core and Libraries 58 | api(projects.core.commonUiShared) 59 | api(projects.core.navigationShared) 60 | implementation(projects.libraries.koinComposeUtils) 61 | implementation(projects.libraries.coroutinesUtils) 62 | implementation(projects.libraries.composeStableWrappers) 63 | 64 | // Ktor 65 | implementation(libs.ktor.client.core) 66 | implementation(libs.ktor.client.json) 67 | implementation(libs.ktor.client.logging) 68 | implementation(libs.ktor.client.serialization) 69 | implementation( 70 | libs 71 | .ktor 72 | .client 73 | .content 74 | .negotiation, 75 | ) 76 | implementation( 77 | libs 78 | .ktor 79 | .serialization 80 | .kotlinx 81 | .json, 82 | ) 83 | 84 | // KotlinX Serialization 85 | implementation(libs.kotlinx.serialization.core) 86 | implementation(libs.kotlinx.serialization.json) 87 | 88 | // KotlinX Coroutines 89 | implementation(libs.kotlinx.coroutines.core) 90 | 91 | // KMP View Model 92 | implementation(libs.kmp.viewmodel) 93 | implementation(libs.kmp.viewmodel.savedstate) 94 | implementation(libs.kmp.viewmodel.compose) 95 | implementation(libs.kmp.viewmodel.koin.compose) 96 | 97 | // FlowExt 98 | implementation(libs.flow.ext) 99 | 100 | // Koin 101 | implementation(libs.koin.annotations) 102 | implementation(libs.koin.core) 103 | implementation(libs.koin.compose) 104 | 105 | // Arrow-kt 106 | implementation(libs.arrow.core) 107 | implementation(libs.arrow.fx.coroutines) 108 | 109 | // KotlinX Utils 110 | implementation(libs.kotlinx.collections.immutable) 111 | implementation(libs.kotlinx.datetime) 112 | 113 | // Kamel Image 114 | implementation(libs.kamel.image) 115 | 116 | // Napier 117 | api(libs.napier) 118 | } 119 | } 120 | androidMain { 121 | dependencies { 122 | api(libs.androidx.activity.compose) 123 | api(libs.androidx.appcompat) 124 | api(libs.androidx.core) 125 | 126 | // Ktor 127 | implementation(libs.ktor.client.okhttp) 128 | 129 | // KotlinX Coroutines 130 | implementation(libs.kotlinx.coroutines.android) 131 | } 132 | } 133 | iosMain { 134 | dependencies { 135 | // Ktor 136 | implementation(libs.ktor.client.darwin) 137 | } 138 | } 139 | val desktopMain by getting { 140 | dependencies { 141 | implementation(compose.desktop.common) 142 | 143 | // Ktor 144 | implementation(libs.ktor.client.java) 145 | 146 | // KotlinX Coroutines 147 | implementation(libs.kotlinx.coroutines.swing) 148 | } 149 | } 150 | } 151 | } 152 | 153 | android { 154 | namespace = "com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo" 155 | 156 | sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") 157 | sourceSets["main"].res.srcDirs("src/androidMain/res") 158 | sourceSets["main"].resources.srcDirs("src/commonMain/resources") 159 | 160 | compileSdk = 161 | libs 162 | .versions 163 | .android 164 | .compile 165 | .map { it.toInt() } 166 | .get() 167 | defaultConfig { 168 | minSdk = 169 | libs 170 | .versions 171 | .android 172 | .min 173 | .map { it.toInt() } 174 | .get() 175 | } 176 | 177 | compileOptions { 178 | sourceCompatibility = 179 | JavaVersion.toVersion( 180 | libs 181 | .versions 182 | .java 183 | .target 184 | .get(), 185 | ) 186 | targetCompatibility = 187 | JavaVersion.toVersion( 188 | libs 189 | .versions 190 | .java 191 | .target 192 | .get(), 193 | ) 194 | } 195 | 196 | buildFeatures { 197 | buildConfig = true 198 | } 199 | } 200 | 201 | // ---------------------------- BUILD KONFIG ---------------------------- 202 | 203 | buildkonfig { 204 | packageName = "com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo" 205 | defaultConfigs { 206 | buildConfigField( 207 | type = FieldSpec.Type.STRING, 208 | name = "UNSPLASH_CLIENT_ID", 209 | value = "none", 210 | ) 211 | buildConfigField( 212 | type = FieldSpec.Type.STRING, 213 | name = "UNSPLASH_BASE_URL", 214 | value = "https://api.unsplash.com/", 215 | ) 216 | buildConfigField( 217 | type = FieldSpec.Type.STRING, 218 | name = "FLAVOR", 219 | value = "none", 220 | ) 221 | } 222 | 223 | defaultConfigs(flavor = "dev") { 224 | buildConfigField( 225 | type = FieldSpec.Type.STRING, 226 | name = "UNSPLASH_CLIENT_ID", 227 | value = if (isCiBuild) { 228 | logger.info("CI build, ignore checking existence of local.properties file") 229 | "none" 230 | } else { 231 | rootProject.readPropertiesFile("local.properties")["UNSPLASH_CLIENT_ID_DEV"] 232 | }, 233 | ) 234 | buildConfigField( 235 | type = FieldSpec.Type.STRING, 236 | name = "FLAVOR", 237 | value = "dev", 238 | ) 239 | } 240 | } 241 | 242 | // ---------------------------- KOIN ANNOTATIONS PROCESSOR ---------------------------- 243 | 244 | dependencies { 245 | add("kspCommonMainMetadata", libs.koin.ksp.compiler) 246 | } 247 | 248 | tasks.withType>().all { 249 | if (name != "kspCommonMainKotlinMetadata") { 250 | dependsOn("kspCommonMainKotlinMetadata") 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/androidMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/data/PlatformSearchPhotoErrorMapper.android.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.data 2 | 3 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.domain.SearchPhotoError 4 | import java.io.IOException 5 | import java.net.SocketException 6 | import java.net.SocketTimeoutException 7 | import java.net.UnknownHostException 8 | import org.koin.core.annotation.Singleton 9 | 10 | @Singleton 11 | internal actual class PlatformSearchPhotoErrorMapper actual constructor() : (Throwable) -> SearchPhotoError? { 12 | override fun invoke(t: Throwable): SearchPhotoError? = 13 | when (t) { 14 | is SearchPhotoError -> t 15 | is IOException -> 16 | when (t) { 17 | is UnknownHostException, is SocketException -> SearchPhotoError.NetworkError 18 | is SocketTimeoutException -> SearchPhotoError.TimeoutError 19 | else -> null 20 | } 21 | 22 | else -> null 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/androidMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/data/di.android.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.data 2 | 3 | import io.ktor.client.HttpClient 4 | import io.ktor.client.engine.okhttp.OkHttp 5 | import kotlinx.serialization.json.Json 6 | import org.koin.core.annotation.Singleton 7 | 8 | @Singleton 9 | internal actual fun createHttpClient(json: Json): HttpClient = 10 | com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.data.remote.createHttpClient( 11 | engineFactory = OkHttp, 12 | json = json, 13 | ) {} 14 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/androidMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/main.android.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.NonRestartableComposable 5 | import androidx.compose.ui.Modifier 6 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.navigation_shared.SearchPhotoScreenRoute 7 | 8 | @Composable 9 | @NonRestartableComposable 10 | fun SearchPhotoScreen( 11 | route: SearchPhotoScreenRoute, 12 | modifier: Modifier = Modifier, 13 | ) = SearchPhotoScreenWithKoin( 14 | route = route, 15 | modifier = modifier, 16 | ) 17 | 18 | actual fun isDebug(): Boolean = BuildConfig.DEBUG 19 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/data/RealSearchPhotoRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.data 2 | 3 | import arrow.core.Either 4 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.coroutines_utils.AppCoroutineDispatchers 5 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.data.remote.UnsplashApi 6 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.data.remote.response.CoverPhotoResponse 7 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.domain.CoverPhoto 8 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.domain.SearchPhotoRepository 9 | import io.github.aakira.napier.Napier 10 | import kotlinx.coroutines.withContext 11 | import org.koin.core.annotation.Singleton 12 | 13 | @Singleton( 14 | binds = [ 15 | SearchPhotoRepository::class, 16 | ], 17 | ) 18 | internal class RealSearchPhotoRepository( 19 | private val unsplashApi: UnsplashApi, 20 | private val searchPhotoErrorMapper: SearchPhotoErrorMapper, 21 | private val appCoroutineDispatchers: AppCoroutineDispatchers, 22 | ) : SearchPhotoRepository { 23 | override suspend fun search(query: String) = 24 | withContext(appCoroutineDispatchers.io) { 25 | Either 26 | .catch { 27 | unsplashApi 28 | .searchPhotos(query) 29 | .results 30 | .map(CoverPhotoResponse::toCoverPhoto) 31 | }.onLeft { 32 | Napier.e( 33 | throwable = it, 34 | tag = "RealSearchPhotoRepository", 35 | message = "search($query) failed", 36 | ) 37 | }.mapLeft(searchPhotoErrorMapper) 38 | } 39 | } 40 | 41 | private fun CoverPhotoResponse.toCoverPhoto() = 42 | CoverPhoto( 43 | id = id, 44 | slug = slug, 45 | createdAt = createdAt, 46 | updatedAt = updatedAt, 47 | promotedAt = promotedAt, 48 | width = width, 49 | height = height, 50 | thumbnailUrl = urls.thumb, 51 | ) 52 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/data/di.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.data 2 | 3 | import io.ktor.client.HttpClient 4 | import kotlinx.datetime.Instant 5 | import kotlinx.serialization.KSerializer 6 | import kotlinx.serialization.descriptors.PrimitiveKind 7 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor 8 | import kotlinx.serialization.descriptors.SerialDescriptor 9 | import kotlinx.serialization.encoding.Decoder 10 | import kotlinx.serialization.encoding.Encoder 11 | import kotlinx.serialization.json.Json 12 | import kotlinx.serialization.modules.SerializersModule 13 | import org.koin.core.annotation.ComponentScan 14 | import org.koin.core.annotation.Module 15 | import org.koin.core.annotation.Singleton 16 | import org.koin.ksp.generated.module 17 | 18 | @Module 19 | @ComponentScan 20 | internal class DataModule 21 | 22 | @Suppress("NOTHING_TO_INLINE") 23 | internal inline fun dataModule() = DataModule().module 24 | 25 | @Singleton 26 | internal expect fun createHttpClient(json: Json): HttpClient 27 | 28 | @Singleton 29 | internal fun createJson(): Json = 30 | Json { 31 | serializersModule = 32 | SerializersModule { 33 | contextual(Instant::class, InstantSerializer) 34 | } 35 | ignoreUnknownKeys = true 36 | coerceInputValues = true 37 | prettyPrint = true 38 | isLenient = true 39 | encodeDefaults = true 40 | allowSpecialFloatingPointValues = true 41 | allowStructuredMapKeys = true 42 | useArrayPolymorphism = false 43 | } 44 | 45 | internal object InstantSerializer : KSerializer { 46 | override val descriptor: SerialDescriptor = 47 | PrimitiveSerialDescriptor( 48 | "InstantSerializer", 49 | PrimitiveKind.STRING, 50 | ) 51 | 52 | override fun serialize( 53 | encoder: Encoder, 54 | value: Instant, 55 | ) = encoder.encodeString(value.toString()) 56 | 57 | override fun deserialize(decoder: Decoder): Instant = Instant.parse(decoder.decodeString()) 58 | } 59 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/data/errorMapper.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.data 2 | 3 | import arrow.core.nonFatalOrThrow 4 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.domain.SearchPhotoError 5 | import io.github.aakira.napier.Napier 6 | import io.ktor.client.network.sockets.ConnectTimeoutException 7 | import io.ktor.client.network.sockets.SocketTimeoutException 8 | import io.ktor.client.plugins.HttpRequestTimeoutException 9 | import io.ktor.client.plugins.ResponseException 10 | import io.ktor.util.cio.ChannelReadException 11 | import org.koin.core.annotation.Singleton 12 | 13 | internal interface SearchPhotoErrorMapper : (Throwable) -> SearchPhotoError 14 | 15 | @Singleton( 16 | binds = [], 17 | ) 18 | internal expect class PlatformSearchPhotoErrorMapper constructor() : (Throwable) -> SearchPhotoError? 19 | 20 | @Singleton( 21 | binds = [ 22 | SearchPhotoErrorMapper::class, 23 | ], 24 | ) 25 | internal class RealSearchPhotoErrorMapper( 26 | private val platformMapper: PlatformSearchPhotoErrorMapper, 27 | ) : SearchPhotoErrorMapper { 28 | override fun invoke(throwable: Throwable): SearchPhotoError { 29 | Napier.d("SearchPhotoErrorMapperImpl.map $throwable") 30 | 31 | val t = throwable.nonFatalOrThrow() 32 | 33 | // Platform mapper has higher priority. 34 | // If it returns non-null value, then return it, and ignore the rest. 35 | platformMapper(t)?.let { 36 | Napier.d("platformSearchPhotoErrorMapper.map -> $it") 37 | return it 38 | } 39 | 40 | return when (t) { 41 | // Already mapped error 42 | is SearchPhotoError -> t 43 | 44 | // Server error 45 | is ResponseException -> SearchPhotoError.ServerError 46 | 47 | // Timeout error 48 | is HttpRequestTimeoutException, 49 | is ConnectTimeoutException, 50 | is SocketTimeoutException, 51 | -> SearchPhotoError.TimeoutError 52 | 53 | // Network error 54 | is ChannelReadException -> SearchPhotoError.NetworkError 55 | 56 | // Unexpected error 57 | else -> SearchPhotoError.Unexpected 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/data/remote/KtorUnsplashApi.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.data.remote 2 | 3 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.BuildKonfig 4 | import io.ktor.client.HttpClient 5 | import io.ktor.client.call.body 6 | import io.ktor.client.request.get 7 | import io.ktor.client.request.header 8 | import io.ktor.http.HttpHeaders 9 | import io.ktor.http.URLBuilder 10 | import io.ktor.http.path 11 | import org.koin.core.annotation.Singleton 12 | 13 | @Singleton( 14 | binds = [ 15 | UnsplashApi::class, 16 | ], 17 | ) 18 | internal class KtorUnsplashApi( 19 | private val httpClient: HttpClient, 20 | ) : UnsplashApi { 21 | override suspend fun searchPhotos(query: String) = 22 | httpClient 23 | .get( 24 | URLBuilder(BuildKonfig.UNSPLASH_BASE_URL) 25 | .apply { 26 | path("search/photos") 27 | parameters.run { 28 | append("query", query) 29 | append("page", "1") 30 | append("per_page", "30") 31 | } 32 | }.build(), 33 | ) { 34 | header( 35 | HttpHeaders.Authorization, 36 | "Client-ID ${BuildKonfig.UNSPLASH_CLIENT_ID}", 37 | ) 38 | }.body() 39 | } 40 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/data/remote/SearchPhotosResult.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("ktlint:standard:discouraged-comment-location") 2 | 3 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.data.remote 4 | 5 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.data.remote.response.CoverPhotoResponse 6 | import kotlinx.serialization.SerialName 7 | import kotlinx.serialization.Serializable 8 | 9 | @Serializable 10 | internal data class SearchPhotosResult( 11 | @SerialName(value = "total") val total: Int, 12 | @SerialName(value = "total_pages") val totalPages: Int, 13 | @SerialName(value = "results") val results: List, 14 | ) 15 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/data/remote/UnsplashApi.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.data.remote 2 | 3 | internal interface UnsplashApi { 4 | suspend fun searchPhotos(query: String): SearchPhotosResult 5 | } 6 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/data/remote/createHttpClient.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.data.remote 2 | 3 | import io.github.aakira.napier.Napier 4 | import io.ktor.client.HttpClient 5 | import io.ktor.client.engine.HttpClientEngineConfig 6 | import io.ktor.client.engine.HttpClientEngineFactory 7 | import io.ktor.client.plugins.HttpTimeout 8 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 9 | import io.ktor.client.plugins.logging.LogLevel 10 | import io.ktor.client.plugins.logging.Logger 11 | import io.ktor.client.plugins.logging.Logging 12 | import io.ktor.http.ContentType 13 | import io.ktor.serialization.kotlinx.KotlinxSerializationConverter 14 | import io.ktor.serialization.kotlinx.json.json 15 | import kotlinx.serialization.json.Json 16 | 17 | internal fun createHttpClient( 18 | engineFactory: HttpClientEngineFactory, 19 | json: Json, 20 | block: T.() -> Unit, 21 | ): HttpClient = 22 | HttpClient(engineFactory) { 23 | engine(block) 24 | 25 | install(HttpTimeout) { 26 | requestTimeoutMillis = 15_000 27 | connectTimeoutMillis = 10_000 28 | socketTimeoutMillis = 10_000 29 | } 30 | 31 | install(ContentNegotiation) { 32 | json(json) 33 | register( 34 | ContentType.Text.Plain, 35 | KotlinxSerializationConverter(json), 36 | ) 37 | } 38 | 39 | install(Logging) { 40 | level = LogLevel.ALL 41 | logger = 42 | object : Logger { 43 | override fun log(message: String) { 44 | Napier.d(message = message, tag = "[HttpClient]") 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/data/remote/response/responses.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress( 2 | "ktlint:standard:discouraged-comment-location", 3 | "ktlint:standard:max-line-length", 4 | "ktlint:standard:value-parameter-comment", 5 | ) 6 | 7 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.data.remote.response 8 | 9 | import kotlinx.datetime.Instant 10 | import kotlinx.serialization.SerialName 11 | import kotlinx.serialization.Serializable 12 | 13 | @Serializable 14 | internal data class UrlsResponse( 15 | @SerialName( 16 | value = "raw", 17 | ) val raw: String, // https://images.unsplash.com/photo-1560089000-7433a4ebbd64?ixlib=rb-4.0.3 18 | @SerialName( 19 | value = "full", 20 | ) val full: String, // https://images.unsplash.com/photo-1560089000-7433a4ebbd64?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb 21 | @SerialName( 22 | value = "regular", 23 | ) val regular: String, // https://images.unsplash.com/photo-1560089000-7433a4ebbd64?ixlib=rb-4.0.3&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=1080&fit=max 24 | @SerialName( 25 | value = "small", 26 | ) val small: String, // https://images.unsplash.com/photo-1560089000-7433a4ebbd64?ixlib=rb-4.0.3&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=400&fit=max 27 | @SerialName( 28 | value = "thumb", 29 | ) val thumb: String, // https://images.unsplash.com/photo-1560089000-7433a4ebbd64?ixlib=rb-4.0.3&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=200&fit=max 30 | @SerialName( 31 | value = "small_s3", 32 | ) val smallS3: String, // https://s3.us-west-2.amazonaws.com/images.unsplash.com/small/photo-1560089000-7433a4ebbd64 33 | ) 34 | 35 | @Serializable 36 | internal data class LinksResponse( 37 | @SerialName(value = "self") val self: String, // https://api.unsplash.com/photos/mzt0A967scs 38 | @SerialName(value = "html") val html: String, // https://unsplash.com/photos/mzt0A967scs 39 | @SerialName(value = "download") val download: String, // https://unsplash.com/photos/mzt0A967scs/download 40 | @SerialName( 41 | value = "download_location", 42 | ) val downloadLocation: String, // https://api.unsplash.com/photos/mzt0A967scs/download 43 | ) 44 | 45 | @Serializable 46 | internal data class CoverPhotoResponse( 47 | @SerialName(value = "id") val id: String, // mzt0A967scs 48 | @SerialName(value = "slug") val slug: String, // mzt0A967scs 49 | @SerialName(value = "created_at") val createdAt: Instant, // 2023-03-10T14:13:07Z 50 | @SerialName(value = "updated_at") val updatedAt: Instant, // 2023-04-15T00:20:56Z 51 | @SerialName(value = "promoted_at") val promotedAt: Instant?, // 2022-12-20T11:44:03Z 52 | @SerialName(value = "width") val width: Int, // 4672 53 | @SerialName(value = "height") val height: Int, // 7008 54 | @SerialName(value = "color") val color: String, // #262626 55 | @SerialName(value = "blur_hash") val blurHash: String, // LNBp;G?w%2aJRkt7V@WAOuWZWARO 56 | @SerialName(value = "description") val description: String?, // Tek it married 57 | @SerialName( 58 | value = "alt_description", 59 | ) val altDescription: String?, // a man holding a basketball standing next to a fence 60 | @SerialName(value = "urls") val urls: UrlsResponse, 61 | @SerialName(value = "links") val links: LinksResponse, 62 | @SerialName(value = "likes") val likes: Int, // 2 63 | @SerialName(value = "liked_by_user") val likedByUser: Boolean, // false 64 | ) 65 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/domain/CoverPhoto.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.domain 2 | 3 | import androidx.compose.runtime.Immutable 4 | import kotlinx.datetime.Instant 5 | 6 | @Immutable 7 | data class CoverPhoto( 8 | val id: String, 9 | val slug: String, 10 | val createdAt: Instant, 11 | val updatedAt: Instant, 12 | val promotedAt: Instant?, 13 | val width: Int, 14 | val height: Int, 15 | val thumbnailUrl: String, 16 | ) 17 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/domain/SearchPhotoError.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.domain 2 | 3 | import androidx.compose.runtime.Immutable 4 | 5 | @Immutable 6 | sealed interface SearchPhotoError { 7 | data object NetworkError : SearchPhotoError 8 | 9 | data object TimeoutError : SearchPhotoError 10 | 11 | data object ServerError : SearchPhotoError 12 | 13 | data object Unexpected : SearchPhotoError 14 | } 15 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/domain/SearchPhotoRepository.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.domain 2 | 3 | import arrow.core.Either 4 | 5 | interface SearchPhotoRepository { 6 | suspend fun search(query: String): Either> 7 | } 8 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/domain/SearchPhotoUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.domain 2 | 3 | import arrow.core.Either 4 | import org.koin.core.annotation.Factory 5 | 6 | @Factory 7 | class SearchPhotoUseCase( 8 | private val searchPhotoRepository: SearchPhotoRepository, 9 | ) { 10 | suspend operator fun invoke(query: String): Either> = 11 | searchPhotoRepository.search(query) 12 | } 13 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/domain/di.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.domain 2 | 3 | import org.koin.core.annotation.ComponentScan 4 | import org.koin.core.annotation.Module 5 | import org.koin.ksp.generated.module 6 | 7 | @Module 8 | @ComponentScan 9 | internal class DomainModule 10 | 11 | @Suppress("NOTHING_TO_INLINE") 12 | internal inline fun domainModule() = DomainModule().module 13 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/main.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.Immutable 5 | import androidx.compose.runtime.SideEffect 6 | import androidx.compose.runtime.Stable 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.ui.Modifier 9 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.koin_compose_utils.rememberKoinModulesOnRoute 10 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.navigation_shared.SearchPhotoScreenRoute 11 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.data.dataModule 12 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.domain.domainModule 13 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.presentation.SearchPhotoScreen 14 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.presentation.presentationModule 15 | import io.github.aakira.napier.Napier 16 | import kotlin.jvm.JvmField 17 | import org.koin.dsl.module 18 | 19 | @JvmField 20 | internal val FeatureSearchPhotoModule = 21 | module { 22 | includes( 23 | dataModule(), 24 | domainModule(), 25 | presentationModule(), 26 | ) 27 | } 28 | 29 | @Composable 30 | internal fun SearchPhotoScreenWithKoin( 31 | route: SearchPhotoScreenRoute, 32 | modifier: Modifier = Modifier, 33 | ) { 34 | val loaded by rememberKoinModulesOnRoute( 35 | route = route, 36 | unloadModules = true, 37 | ) { listOf(FeatureSearchPhotoModule) } 38 | 39 | if (loaded) { 40 | SearchPhotoScreen(modifier = modifier) 41 | } else { 42 | SideEffect { 43 | Napier.d( 44 | message = "SearchPhotoScreenWithKoin: unloaded", 45 | tag = "SearchPhotoScreenWithKoin", 46 | ) 47 | } 48 | } 49 | } 50 | 51 | @Immutable 52 | enum class BuildFlavor { 53 | DEV, 54 | PROD, 55 | ; 56 | 57 | companion object { 58 | @Stable 59 | val Current: BuildFlavor by lazy { 60 | when (BuildKonfig.FLAVOR) { 61 | "dev" -> DEV 62 | "prod" -> PROD 63 | else -> error("Unknown flavor ${BuildKonfig.FLAVOR}") 64 | } 65 | } 66 | } 67 | } 68 | 69 | expect fun isDebug(): Boolean 70 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/presentation/SearchPhotoScreen.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.presentation 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.ExperimentalLayoutApi 8 | import androidx.compose.foundation.layout.PaddingValues 9 | import androidx.compose.foundation.layout.Spacer 10 | import androidx.compose.foundation.layout.aspectRatio 11 | import androidx.compose.foundation.layout.consumeWindowInsets 12 | import androidx.compose.foundation.layout.fillMaxSize 13 | import androidx.compose.foundation.layout.fillMaxWidth 14 | import androidx.compose.foundation.layout.height 15 | import androidx.compose.foundation.layout.padding 16 | import androidx.compose.foundation.lazy.grid.GridCells 17 | import androidx.compose.foundation.lazy.grid.LazyGridState 18 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 19 | import androidx.compose.foundation.lazy.grid.items 20 | import androidx.compose.foundation.lazy.grid.rememberLazyGridState 21 | import androidx.compose.material3.CenterAlignedTopAppBar 22 | import androidx.compose.material3.ExperimentalMaterial3Api 23 | import androidx.compose.material3.Scaffold 24 | import androidx.compose.material3.Text 25 | import androidx.compose.material3.TextField 26 | import androidx.compose.runtime.Composable 27 | import androidx.compose.runtime.getValue 28 | import androidx.compose.runtime.remember 29 | import androidx.compose.ui.Alignment 30 | import androidx.compose.ui.Modifier 31 | import androidx.compose.ui.unit.dp 32 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.common_ui.components.EmptyView 33 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.common_ui.components.ErrorMessageAndRetryButton 34 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.common_ui.components.LoadingIndicator 35 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.coroutines_utils.AppCoroutineDispatchers 36 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.domain.SearchPhotoError 37 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.presentation.SearchPhotoUiState.PhotoUiItem 38 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.presentation.components.PhotoGridCell 39 | import com.hoc081098.kmp.viewmodel.koin.compose.koinKmpViewModel 40 | import com.hoc081098.solivagant.lifecycle.compose.collectAsStateWithLifecycle 41 | import org.koin.compose.koinInject 42 | 43 | @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) 44 | @Composable 45 | internal fun SearchPhotoScreen( 46 | modifier: Modifier = Modifier, 47 | viewModel: SearchPhotoViewModel = koinKmpViewModel(), 48 | appCoroutineDispatchers: AppCoroutineDispatchers = koinInject(), 49 | ) { 50 | val state by viewModel.stateFlow.collectAsStateWithLifecycle() 51 | val searchTerm by viewModel 52 | .searchTermStateFlow 53 | .collectAsStateWithLifecycle(context = appCoroutineDispatchers.immediateMain) 54 | 55 | Scaffold( 56 | modifier = modifier 57 | .fillMaxSize(), 58 | topBar = { 59 | CenterAlignedTopAppBar( 60 | title = { Text(text = "Unsplash") }, 61 | ) 62 | }, 63 | ) { padding -> 64 | Column( 65 | modifier = Modifier 66 | .padding(padding) 67 | .consumeWindowInsets(padding), 68 | ) { 69 | Spacer(modifier = Modifier.height(16.dp)) 70 | 71 | TextField( 72 | modifier = Modifier 73 | .fillMaxWidth() 74 | .padding(horizontal = 16.dp), 75 | value = searchTerm, 76 | onValueChange = remember(viewModel) { viewModel::search }, 77 | label = { Text(text = "Search term") }, 78 | ) 79 | 80 | Spacer(modifier = Modifier.height(8.dp)) 81 | 82 | Text( 83 | modifier = Modifier 84 | .fillMaxWidth() 85 | .padding(horizontal = 16.dp), 86 | text = "Submitted term: ${state.submittedTerm.orEmpty()}", 87 | ) 88 | 89 | Spacer(modifier = Modifier.height(16.dp)) 90 | 91 | Box( 92 | modifier = Modifier 93 | .fillMaxWidth() 94 | .weight(1f), 95 | contentAlignment = Alignment.Center, 96 | ) { 97 | ListContent( 98 | modifier = Modifier.matchParentSize(), 99 | state = state, 100 | onItemClick = remember(viewModel) { viewModel::navigateToPhotoDetail }, 101 | ) 102 | } 103 | } 104 | } 105 | } 106 | 107 | @OptIn(ExperimentalFoundationApi::class) 108 | @Composable 109 | private fun ListContent( 110 | state: SearchPhotoUiState, 111 | onItemClick: (PhotoUiItem) -> Unit, 112 | modifier: Modifier = Modifier, 113 | lazyGridState: LazyGridState = rememberLazyGridState(), 114 | ) { 115 | if (state.isLoading) { 116 | LoadingIndicator( 117 | modifier = modifier, 118 | ) 119 | return 120 | } 121 | 122 | state.error?.let { error -> 123 | ErrorMessageAndRetryButton( 124 | modifier = modifier, 125 | onRetry = { }, 126 | errorMessage = when (error) { 127 | SearchPhotoError.NetworkError -> "Network error" 128 | SearchPhotoError.ServerError -> "Server error" 129 | SearchPhotoError.TimeoutError -> "Timeout error" 130 | SearchPhotoError.Unexpected -> "Unexpected error" 131 | }, 132 | ) 133 | return 134 | } 135 | 136 | if (state.photoUiItems.isEmpty()) { 137 | EmptyView( 138 | modifier = modifier, 139 | ) 140 | } else { 141 | LazyVerticalGrid( 142 | modifier = modifier, 143 | columns = GridCells.Adaptive(minSize = 128.dp), 144 | state = lazyGridState, 145 | verticalArrangement = Arrangement.spacedBy(16.dp), 146 | horizontalArrangement = Arrangement.spacedBy(16.dp), 147 | contentPadding = PaddingValues(16.dp), 148 | ) { 149 | items( 150 | items = state.photoUiItems, 151 | key = { it.id }, 152 | ) { 153 | PhotoGridCell( 154 | modifier = Modifier 155 | .animateItemPlacement() 156 | .fillMaxWidth() 157 | .aspectRatio(1f), 158 | photo = it, 159 | onClick = { onItemClick(it) }, 160 | ) 161 | } 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/presentation/SearchPhotoUiState.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.presentation 2 | 3 | import androidx.compose.runtime.Immutable 4 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.domain.SearchPhotoError 5 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.stable_wrappers.ImmutableWrapper 6 | import kotlinx.collections.immutable.ImmutableList 7 | import kotlinx.collections.immutable.persistentListOf 8 | import kotlinx.datetime.Instant 9 | 10 | @Immutable 11 | data class SearchPhotoUiState( 12 | val photoUiItems: ImmutableList, 13 | val isLoading: Boolean, 14 | val error: SearchPhotoError?, 15 | val submittedTerm: String?, 16 | ) { 17 | @Immutable 18 | data class PhotoUiItem( 19 | val id: String, 20 | val slug: String, 21 | val createdAt: ImmutableWrapper, 22 | val updatedAt: ImmutableWrapper, 23 | val promotedAt: ImmutableWrapper, 24 | val width: Int, 25 | val height: Int, 26 | val thumbnailUrl: String, 27 | ) 28 | 29 | companion object { 30 | val INITIAL = 31 | SearchPhotoUiState( 32 | photoUiItems = persistentListOf(), 33 | isLoading = false, 34 | error = null, 35 | submittedTerm = null, 36 | ) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/presentation/SearchPhotoViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.presentation 2 | 3 | import arrow.core.right 4 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.navigation_shared.PhotoDetailScreenRoute 5 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.domain.CoverPhoto 6 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.domain.SearchPhotoUseCase 7 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.presentation.SearchPhotoUiState.PhotoUiItem 8 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.stable_wrappers.toImmutableWrapper 9 | import com.hoc081098.flowext.flowFromSuspend 10 | import com.hoc081098.flowext.startWith 11 | import com.hoc081098.kmp.viewmodel.SavedStateHandle 12 | import com.hoc081098.kmp.viewmodel.ViewModel 13 | import com.hoc081098.kmp.viewmodel.safe.NonNullSavedStateHandleKey 14 | import com.hoc081098.kmp.viewmodel.safe.safe 15 | import com.hoc081098.kmp.viewmodel.safe.string 16 | import com.hoc081098.solivagant.navigation.NavEventNavigator 17 | import io.github.aakira.napier.Napier 18 | import kotlin.time.Duration.Companion.milliseconds 19 | import kotlinx.collections.immutable.persistentListOf 20 | import kotlinx.collections.immutable.toImmutableList 21 | import kotlinx.coroutines.ExperimentalCoroutinesApi 22 | import kotlinx.coroutines.FlowPreview 23 | import kotlinx.coroutines.flow.Flow 24 | import kotlinx.coroutines.flow.SharingStarted 25 | import kotlinx.coroutines.flow.StateFlow 26 | import kotlinx.coroutines.flow.debounce 27 | import kotlinx.coroutines.flow.distinctUntilChanged 28 | import kotlinx.coroutines.flow.flatMapLatest 29 | import kotlinx.coroutines.flow.map 30 | import kotlinx.coroutines.flow.onEach 31 | import kotlinx.coroutines.flow.onStart 32 | import kotlinx.coroutines.flow.stateIn 33 | import org.koin.core.annotation.Factory 34 | 35 | @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) 36 | @Factory 37 | internal class SearchPhotoViewModel( 38 | private val savedStateHandle: SavedStateHandle, 39 | private val searchPhotoUseCase: SearchPhotoUseCase, 40 | private val navigator: NavEventNavigator, 41 | ) : ViewModel() { 42 | val searchTermStateFlow: StateFlow = 43 | savedStateHandle 44 | .safe 45 | .getStateFlow(key = SEARCH_TERM_KEY) 46 | 47 | val stateFlow: StateFlow = 48 | searchTermStateFlow 49 | .debounce(400.milliseconds) 50 | .map { it.orEmpty().trim() } 51 | .distinctUntilChanged() 52 | .flatMapLatest(searchPhotoUseCase::executeSearching) 53 | .stateIn( 54 | scope = viewModelScope, 55 | started = SharingStarted.Lazily, 56 | initialValue = SearchPhotoUiState.INITIAL, 57 | ) 58 | 59 | init { 60 | Napier.d(message = "init $this", tag = "SearchPhotoViewModel") 61 | addCloseable { Napier.d(message = "close $this", tag = "SearchPhotoViewModel") } 62 | } 63 | 64 | fun search(term: String) = savedStateHandle.safe { it[SEARCH_TERM_KEY] = term } 65 | 66 | fun navigateToPhotoDetail(item: PhotoUiItem) = navigator.navigateTo(PhotoDetailScreenRoute(id = item.id)) 67 | 68 | companion object { 69 | private val SEARCH_TERM_KEY = 70 | NonNullSavedStateHandleKey.string( 71 | key = "com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.presentation.search_term", 72 | defaultValue = "", 73 | ) 74 | } 75 | } 76 | 77 | private fun SearchPhotoUseCase.executeSearching(term: String): Flow = 78 | flowFromSuspend { 79 | if (term.isEmpty()) { 80 | emptyList().right() 81 | } else { 82 | invoke(term) 83 | } 84 | }.onStart { Napier.d("search products term=$term") } 85 | .onEach { either -> 86 | Napier.d( 87 | "search products ${ 88 | either.fold( 89 | ifLeft = { "error=$it" }, 90 | ifRight = { "success=${it.size}" }, 91 | ) 92 | }", 93 | ) 94 | }.map { either -> 95 | either.fold( 96 | ifLeft = { 97 | SearchPhotoUiState( 98 | isLoading = false, 99 | error = it, 100 | submittedTerm = term, 101 | photoUiItems = persistentListOf(), 102 | ) 103 | }, 104 | ifRight = { coverPhotos -> 105 | SearchPhotoUiState( 106 | photoUiItems = 107 | coverPhotos 108 | .map { it.toPhotoUiItem() } 109 | .toImmutableList(), 110 | isLoading = false, 111 | error = null, 112 | submittedTerm = term, 113 | ) 114 | }, 115 | ) 116 | }.startWith { 117 | SearchPhotoUiState( 118 | isLoading = true, 119 | error = null, 120 | submittedTerm = term, 121 | photoUiItems = persistentListOf(), 122 | ) 123 | } 124 | 125 | private fun CoverPhoto.toPhotoUiItem(): PhotoUiItem = 126 | PhotoUiItem( 127 | id = id, 128 | slug = slug, 129 | createdAt = createdAt.toImmutableWrapper(), 130 | updatedAt = updatedAt.toImmutableWrapper(), 131 | promotedAt = promotedAt.toImmutableWrapper(), 132 | width = width, 133 | height = height, 134 | thumbnailUrl = thumbnailUrl, 135 | ) 136 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/presentation/components/PhotoGridCell.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.presentation.components 2 | 3 | import androidx.compose.animation.core.tween 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.filled.Info 8 | import androidx.compose.material3.Icon 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.layout.ContentScale 13 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.common_ui.components.LoadingIndicator 14 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.presentation.SearchPhotoUiState.PhotoUiItem 15 | import io.kamel.image.KamelImage 16 | import io.kamel.image.asyncPainterResource 17 | 18 | @Composable 19 | internal fun PhotoGridCell( 20 | photo: PhotoUiItem, 21 | onClick: () -> Unit, 22 | modifier: Modifier = Modifier, 23 | ) { 24 | Box( 25 | modifier = 26 | modifier 27 | .clickable(onClick = onClick), 28 | ) { 29 | KamelImage( 30 | modifier = Modifier.matchParentSize(), 31 | resource = asyncPainterResource(data = photo.thumbnailUrl), 32 | contentDescription = null, 33 | contentScale = ContentScale.Crop, 34 | animationSpec = tween(), 35 | onLoading = { 36 | LoadingIndicator( 37 | modifier = Modifier.matchParentSize(), 38 | ) 39 | }, 40 | onFailure = { 41 | Icon( 42 | modifier = Modifier.align(Alignment.Center), 43 | imageVector = Icons.Default.Info, 44 | contentDescription = null, 45 | ) 46 | }, 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/presentation/di.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.presentation 2 | 3 | import org.koin.core.annotation.ComponentScan 4 | import org.koin.core.annotation.Module 5 | import org.koin.ksp.generated.module 6 | 7 | @Module 8 | @ComponentScan 9 | internal class PresentationModule 10 | 11 | @Suppress("NOTHING_TO_INLINE") 12 | internal inline fun presentationModule() = PresentationModule().module 13 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/commonMain/resources/compose-multiplatform.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 24 | 30 | 36 | 37 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/desktopMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/data/PlatformSearchPhotoErrorMapper.desktop.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.data 2 | 3 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.domain.SearchPhotoError 4 | import java.io.IOException 5 | import java.net.SocketException 6 | import java.net.SocketTimeoutException 7 | import java.net.UnknownHostException 8 | import org.koin.core.annotation.Singleton 9 | 10 | @Singleton 11 | internal actual class PlatformSearchPhotoErrorMapper actual constructor() : (Throwable) -> SearchPhotoError? { 12 | override fun invoke(t: Throwable): SearchPhotoError? = 13 | when (t) { 14 | is SearchPhotoError -> t 15 | is IOException -> 16 | when (t) { 17 | is UnknownHostException, is SocketException -> SearchPhotoError.NetworkError 18 | is SocketTimeoutException -> SearchPhotoError.TimeoutError 19 | else -> null 20 | } 21 | 22 | else -> null 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/desktopMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/data/di.desktop.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.data 2 | 3 | import io.ktor.client.HttpClient 4 | import io.ktor.client.engine.java.Java 5 | import kotlinx.serialization.json.Json 6 | import org.koin.core.annotation.Singleton 7 | 8 | @Singleton 9 | internal actual fun createHttpClient(json: Json): HttpClient = 10 | com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.data.remote.createHttpClient( 11 | engineFactory = Java, 12 | json = json, 13 | ) {} 14 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/desktopMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/main.desktop.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.NonRestartableComposable 5 | import androidx.compose.ui.Modifier 6 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.navigation_shared.SearchPhotoScreenRoute 7 | 8 | @NonRestartableComposable 9 | @Composable 10 | fun SearchPhotoScreen( 11 | route: SearchPhotoScreenRoute, 12 | modifier: Modifier = Modifier, 13 | ) = SearchPhotoScreenWithKoin( 14 | modifier = modifier, 15 | route = route, 16 | ) 17 | 18 | actual fun isDebug(): Boolean = true 19 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/iosMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/data/PlatformSearchPhotoErrorMapper.ios.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.data 2 | 3 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.domain.SearchPhotoError 4 | import io.ktor.client.engine.darwin.DarwinHttpRequestException 5 | import io.ktor.client.network.sockets.SocketTimeoutException 6 | import org.koin.core.annotation.Singleton 7 | import platform.Foundation.NSURLErrorDomain 8 | import platform.Foundation.NSURLErrorNetworkConnectionLost 9 | import platform.Foundation.NSURLErrorNotConnectedToInternet 10 | 11 | @Singleton 12 | internal actual class PlatformSearchPhotoErrorMapper actual constructor() : (Throwable) -> SearchPhotoError? { 13 | override fun invoke(t: Throwable): SearchPhotoError? = 14 | when (t) { 15 | is SearchPhotoError -> t 16 | is SocketTimeoutException -> SearchPhotoError.TimeoutError 17 | is DarwinHttpRequestException -> 18 | when { 19 | t.origin.domain == NSURLErrorDomain && t.origin.code in NETWORK_ERROR_CODES -> 20 | SearchPhotoError.NetworkError 21 | 22 | else -> null 23 | } 24 | 25 | else -> null 26 | } 27 | 28 | private companion object { 29 | private val NETWORK_ERROR_CODES = 30 | setOf( 31 | NSURLErrorNotConnectedToInternet, 32 | NSURLErrorNetworkConnectionLost, 33 | ) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/iosMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/data/di.ios.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.data 2 | 3 | import io.ktor.client.HttpClient 4 | import io.ktor.client.engine.darwin.Darwin 5 | import kotlinx.serialization.json.Json 6 | import org.koin.core.annotation.Singleton 7 | 8 | @Singleton 9 | internal actual fun createHttpClient(json: Json): HttpClient = 10 | com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo.data.remote.createHttpClient( 11 | engineFactory = Darwin, 12 | json = json, 13 | ) {} 14 | -------------------------------------------------------------------------------- /features/feature_search_photo_shared/src/iosMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/search_photo/main.ios.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.search_photo 2 | 3 | import androidx.compose.ui.window.ComposeUIViewController 4 | import kotlin.experimental.ExperimentalNativeApi 5 | import platform.UIKit.UIViewController 6 | 7 | fun SearchPhotoViewController(navigateToPhotoDetail: (id: String) -> Unit = {}): UIViewController = 8 | ComposeUIViewController { 9 | SearchPhotoScreenWithKoin( 10 | navigateToPhotoDetail = navigateToPhotoDetail, 11 | ) 12 | } 13 | 14 | @OptIn(ExperimentalNativeApi::class) 15 | actual fun isDebug(): Boolean = Platform.isDebugBinary 16 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | #Gradle 2 | org.gradle.jvmargs=-Xmx4096M -Dkotlin.daemon.jvm.options\="-Xmx4096M" 3 | # When configured, Gradle will run in incubating parallel mode. 4 | # This option should only be used with decoupled projects. More details, visit 5 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 6 | org.gradle.parallel=true 7 | org.gradle.configureondemand=true 8 | # Enable the Build Cache 9 | org.gradle.caching=true 10 | 11 | #Kotlin 12 | kotlin.code.style=official 13 | 14 | # Enable Kotlin incremental compilation 15 | kotlin.incremental.multiplatform=true 16 | kotlin.incremental.useClasspathSnapshot=true 17 | kotlin.incremental=true 18 | 19 | #MPP 20 | kotlin.mpp.stability.nowarn=true 21 | kotlin.mpp.enableCInteropCommonization=true 22 | kotlin.mpp.androidSourceSetLayoutVersion=2 23 | 24 | #Compose 25 | org.jetbrains.compose.experimental.uikit.enabled=true 26 | 27 | #Android 28 | android.useAndroidX=true 29 | android.compileSdk=34 30 | android.targetSdk=34 31 | android.minSdk=24 32 | 33 | # Use R8 instead of ProGuard for code shrinking. 34 | android.enableR8.fullMode=true 35 | 36 | # Enable non-transitive R class namespacing where each library only contains 37 | # references to the resources it declares instead of declarations plus all 38 | # transitive dependency references. 39 | android.nonTransitiveRClass=true 40 | 41 | # Default Android build features 42 | android.defaults.buildfeatures.buildconfig=false 43 | android.defaults.buildfeatures.shaders=false 44 | 45 | # do not import irrelevant source sets 46 | import_orphan_source_sets=false 47 | 48 | #Versions 49 | kotlin.version=1.9.0 50 | agp.version=7.4.2 51 | compose.version=1.5.0 52 | 53 | #Build konfig 54 | buildkonfig.flavor=dev 55 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | koinVersion = "4.0.0" 3 | kotlin = "1.9.22" 4 | kotlinx-collections-immutable = "0.3.8" 5 | kotlinx-coroutines = "1.9.0" 6 | kotlinx-atomicfu = "0.26.0" 7 | kotlinx-datetime = "0.6.1" 8 | kotlinx-serialization = "1.6.3" 9 | 10 | jetbrains-compose-mutiplatform = "1.5.12" 11 | 12 | java-target = "17" 13 | java-toolchain = "17" 14 | 15 | ktlint = "1.1.1" 16 | 17 | android-gradle = "8.7.1" 18 | android-min = "24" 19 | android-target = "34" 20 | android-compile = "34" 21 | androidx-appcompat = "1.7.0" 22 | androidx-activity = "1.9.3" 23 | androidx-core = "1.13.1" 24 | 25 | ktor = "2.3.12" 26 | 27 | arrow-kt = "1.2.4" 28 | kamel-image = "0.9.4" 29 | napier = "2.7.1" 30 | flow-ext = "1.0.0" 31 | koin-annotations = "1.3.1" 32 | koin-core = "4.0.0" 33 | koin-compose = "4.0.0" 34 | 35 | kmp-viewmodel = "0.8.0" 36 | solivagant = "0.5.0" 37 | 38 | ksp = "1.9.22-1.0.18" 39 | 40 | [libraries] 41 | koin-android = { module = "io.insert-koin:koin-android", version.ref = "koinVersion" } 42 | koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koinVersion" } 43 | kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } 44 | kotlin-parcelize = { module = "org.jetbrains.kotlin:kotlin-parcelize-runtime", version.ref = "kotlin" } 45 | kotlin-compiler = { module = "org.jetbrains.kotlin:kotlin-compiler-embeddable", version.ref = "kotlin" } 46 | kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } 47 | kotlin-test-annotations = { module = "org.jetbrains.kotlin:kotlin-test-annotations-common", version.ref = "kotlin" } 48 | kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } 49 | 50 | kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-collections-immutable" } 51 | kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } 52 | kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } 53 | kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } 54 | kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } 55 | kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "kotlinx-atomicfu" } 56 | kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } 57 | kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } 58 | kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } 59 | 60 | androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } 61 | androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } 62 | androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } 63 | 64 | ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } 65 | ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" } 66 | ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } 67 | ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } 68 | ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } 69 | ktor-client-serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktor" } 70 | ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } 71 | ktor-client-json = { module = "io.ktor:ktor-client-json", version.ref = "ktor" } 72 | ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } 73 | 74 | arrow-fx-coroutines = { module = "io.arrow-kt:arrow-fx-coroutines", version.ref = "arrow-kt" } 75 | arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow-kt" } 76 | kamel-image = { module = "media.kamel:kamel-image", version.ref = "kamel-image" } 77 | napier = { module = "io.github.aakira:napier", version.ref = "napier" } 78 | flow-ext = { module = "io.github.hoc081098:FlowExt", version.ref = "flow-ext" } 79 | koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koin-annotations" } 80 | koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin-core" } 81 | koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin-compose" } 82 | koin-ksp-compiler = { module = "io.insert-koin:koin-ksp-compiler", version.ref = "koin-annotations" } 83 | 84 | kmp-viewmodel = { module = "io.github.hoc081098:kmp-viewmodel", version.ref = "kmp-viewmodel" } 85 | kmp-viewmodel-savedstate = { module = "io.github.hoc081098:kmp-viewmodel-savedstate", version.ref = "kmp-viewmodel" } 86 | kmp-viewmodel-compose = { module = "io.github.hoc081098:kmp-viewmodel-compose", version.ref = "kmp-viewmodel" } 87 | kmp-viewmodel-koin-compose = { module = "io.github.hoc081098:kmp-viewmodel-koin-compose", version.ref = "kmp-viewmodel" } 88 | solivagant-navigation = { module = "io.github.hoc081098:solivagant-navigation", version.ref = "solivagant" } 89 | 90 | [plugins] 91 | android-library = { id = "com.android.library", version.ref = "android-gradle" } 92 | android-app = { id = "com.android.application", version.ref = "android-gradle" } 93 | 94 | jetbrains-compose-mutiplatform = { id = "org.jetbrains.compose", version.ref = "jetbrains-compose-mutiplatform" } 95 | 96 | kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 97 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 98 | kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 99 | kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } 100 | kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } 101 | 102 | ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } 103 | buildkonfig = { id = "com.codingfeline.buildkonfig", version = "0.15.1" } 104 | spotless = { id = "com.diffplug.gradle.spotless", version = "6.25.0" } 105 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/Compose-Multiplatform-KmpViewModel-Unsplash-Sample/a9f88e18690fc2401a8f936a9792a584a84d2991/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /images/img_0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/Compose-Multiplatform-KmpViewModel-Unsplash-Sample/a9f88e18690fc2401a8f936a9792a584a84d2991/images/img_0.png -------------------------------------------------------------------------------- /images/img_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoc081098/Compose-Multiplatform-KmpViewModel-Unsplash-Sample/a9f88e18690fc2401a8f936a9792a584a84d2991/images/img_1.png -------------------------------------------------------------------------------- /libraries/compose-stable-wrappers/README.md: -------------------------------------------------------------------------------- 1 | # Koin Compose Utils Module 2 | 3 | ## Dependencies 4 | 5 | - Pure Kotlin. 6 | - No Compose Multiplatform plugin. 7 | - Depends on 8 | - `compose-runtime`. 9 | - `project(":libraries:koin-utils")` 10 | 11 | ## Content 12 | 13 | - `StableWrapper`, `toStableWrapper`. 14 | - `ImmutableWrapper`, `toImmutableWrapper`. 15 | -------------------------------------------------------------------------------- /libraries/compose-stable-wrappers/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlin.multiplatform) 3 | } 4 | 5 | @OptIn( 6 | org 7 | .jetbrains 8 | .kotlin 9 | .gradle 10 | .ExperimentalKotlinGradlePluginApi::class, 11 | ) 12 | kotlin { 13 | jvmToolchain { 14 | languageVersion.set( 15 | JavaLanguageVersion.of( 16 | libs 17 | .versions 18 | .java 19 | .toolchain 20 | .get(), 21 | ), 22 | ) 23 | vendor.set(JvmVendorSpec.AZUL) 24 | } 25 | 26 | applyDefaultHierarchyTemplate() 27 | 28 | jvm() 29 | 30 | iosX64() 31 | iosArm64() 32 | iosSimulatorArm64() 33 | 34 | sourceSets { 35 | val commonMain by getting { 36 | dependencies { 37 | api("org.jetbrains.compose.runtime:runtime:${org.jetbrains.compose.ComposeBuildConfig.composeVersion}") 38 | } 39 | } 40 | val commonTest by getting { 41 | dependencies { 42 | implementation(kotlin("test")) 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /libraries/compose-stable-wrappers/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/stable_wrappers/stableWrappers.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.stable_wrappers 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.compose.runtime.Stable 5 | import kotlin.jvm.JvmInline 6 | 7 | @Stable 8 | @JvmInline 9 | value class StableWrapper( 10 | val value: T, 11 | ) { 12 | operator fun component1(): T = value 13 | } 14 | 15 | @Immutable 16 | @JvmInline 17 | value class ImmutableWrapper( 18 | val value: T, 19 | ) { 20 | operator fun component1(): T = value 21 | } 22 | 23 | @Suppress("NOTHING_TO_INLINE") 24 | @Stable 25 | inline fun T.toImmutableWrapper(): ImmutableWrapper = ImmutableWrapper(this) 26 | 27 | @Suppress("NOTHING_TO_INLINE") 28 | @Stable 29 | inline fun T.toStableWrapper(): StableWrapper = StableWrapper(this) 30 | -------------------------------------------------------------------------------- /libraries/coroutines-utils/README.md: -------------------------------------------------------------------------------- 1 | # Coroutines Utils Module 2 | 3 | ## Dependencies 4 | 5 | - Pure Kotlin. 6 | - No Compose Multiplatform plugin and dependencies. 7 | - Depends on: 8 | - `kotlinx-coroutines-core`. 9 | - `FlowExt`. 10 | 11 | ## Content 12 | 13 | - `AppCoroutineDispatchers`. 14 | 15 | - `Flow.publish(selector: suspend SelectorScope.() -> Flow)`. 16 | -------------------------------------------------------------------------------- /libraries/coroutines-utils/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlin.multiplatform) 3 | } 4 | 5 | @OptIn( 6 | org 7 | .jetbrains 8 | .kotlin 9 | .gradle 10 | .ExperimentalKotlinGradlePluginApi::class, 11 | ) 12 | kotlin { 13 | jvmToolchain { 14 | languageVersion.set( 15 | JavaLanguageVersion.of( 16 | libs 17 | .versions 18 | .java 19 | .toolchain 20 | .get(), 21 | ), 22 | ) 23 | vendor.set(JvmVendorSpec.AZUL) 24 | } 25 | 26 | applyDefaultHierarchyTemplate() 27 | 28 | jvm() 29 | 30 | iosX64() 31 | iosArm64() 32 | iosSimulatorArm64() 33 | 34 | sourceSets { 35 | val commonMain by getting { 36 | dependencies { 37 | // KotlinX Coroutines 38 | api(libs.kotlinx.coroutines.core) 39 | 40 | // FlowExt 41 | api(libs.flow.ext) 42 | } 43 | } 44 | val commonTest by getting { 45 | dependencies { 46 | implementation(kotlin("test")) 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /libraries/coroutines-utils/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/coroutines_utils/AppCoroutineDispatchers.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.coroutines_utils 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.IO 6 | import kotlinx.coroutines.MainCoroutineDispatcher 7 | 8 | /** 9 | * An interface that provides properties for accessing commonly used [CoroutineDispatcher]s. This differs from the 10 | * [Dispatchers] object in that it has consistent properties across all platforms and since [AppCoroutineDispatchers] is 11 | * an interface, it can easily be mocked and tested, and different implementations can easily be made to adapt to 12 | * different scenarios. 13 | * 14 | * Each supported platform contains an implementation of this [AppCoroutineDispatchers] interface. 15 | * 16 | * Note that not all platforms natively support all of the [CoroutineDispatcher] types (ex: only JVM supports 17 | * Dispatchers.IO), so fallbacks are provided when they aren't available for the default implementations. 18 | */ 19 | interface AppCoroutineDispatchers { 20 | /** 21 | * The companion object for the [AppCoroutineDispatchers] interface. This is provided so that it's possible to create 22 | * extension functions and properties on the companion object. 23 | */ 24 | companion object 25 | 26 | /** 27 | * The main [CoroutineDispatcher] that is usually used for UI work. Default implementations of this interface, 28 | * refer to [Dispatchers.Main] for this value when it is available. 29 | * 30 | * Note that this isn't available on all platforms, so when one isn't present, this falls back to the [default] 31 | * [CoroutineDispatcher] in the default implementation. 32 | * 33 | * Default implementation [CoroutineDispatcher]: 34 | * Android - Main 35 | * iOS - Custom Main implementation or Default 36 | */ 37 | val main: CoroutineDispatcher 38 | 39 | /** 40 | * The [CoroutineDispatcher] that is usually used for input/output, or intensive operations. Default 41 | * implementations of this interface, refer to Dispatchers.IO for this value when it is available. 42 | * 43 | * Note that this isn't available on all platforms, so when one isn't present, this falls back to the [default] 44 | * [CoroutineDispatcher] in the default implementation. 45 | * 46 | * Default implementation [CoroutineDispatcher]: 47 | * Android - IO 48 | * iOS - Default 49 | */ 50 | val io: CoroutineDispatcher 51 | 52 | /** 53 | * The [CoroutineDispatcher] that is the default that is used by all standard builders like launch and async if no 54 | * other [CoroutineDispatcher] is provided or in their context. Default implementations of this interface, refer to 55 | * [Dispatchers.Default] for this value. 56 | * 57 | * Default implementation [CoroutineDispatcher]: 58 | * Android - Default 59 | * iOS - Default 60 | */ 61 | val default: CoroutineDispatcher 62 | 63 | /** 64 | * The [CoroutineDispatcher] that is not confined to any specific thread. Default implementations of this 65 | * interface refer to [Dispatchers.Unconfined] for this value. 66 | * 67 | * Default implementation [CoroutineDispatcher]: 68 | * Android - Unconfined 69 | * iOS - Unconfined 70 | */ 71 | val unconfined: CoroutineDispatcher 72 | 73 | /** 74 | * Returns dispatcher that executes coroutines immediately when it is already in the right context 75 | * (e.g. current looper is the same as this handler's looper) without an additional [re-dispatch][CoroutineDispatcher.dispatch]. 76 | * 77 | * Immediate dispatcher is safe from stack overflows and in case of nested invocations forms event-loop similar to [Dispatchers.Unconfined]. 78 | * The event loop is an advanced topic and its implications can be found in [Dispatchers.Unconfined] documentation. 79 | * The formed event-loop is shared with [Dispatchers.Unconfined] and other immediate dispatchers, potentially overlapping tasks between them. 80 | * 81 | * Example of usage: 82 | * ``` 83 | * suspend fun updateUiElement(val text: String) { 84 | * /* 85 | * * If it is known that updateUiElement can be invoked both from the Main thread and from other threads, 86 | * * `immediate` dispatcher is used as a performance optimization to avoid unnecessary dispatch. 87 | * * 88 | * * In that case, when `updateUiElement` is invoked from the Main thread, `uiElement.text` will be 89 | * * invoked immediately without any dispatching, otherwise, the `Dispatchers.Main` dispatch cycle will be triggered. 90 | * */ 91 | * withContext(immediateMain) { 92 | * uiElement.text = text 93 | * } 94 | * // Do context-independent logic such as logging 95 | * } 96 | * ``` 97 | * 98 | * Method may throw [UnsupportedOperationException] if immediate dispatching is not supported by current dispatcher, 99 | * please refer to specific dispatcher documentation. 100 | * 101 | * [Dispatchers.Main] supports immediate execution for Android, JavaFx and Swing platforms. 102 | */ 103 | val immediateMain: CoroutineDispatcher 104 | } 105 | 106 | internal class RealAppCoroutineDispatchers : AppCoroutineDispatchers { 107 | override val main: CoroutineDispatcher get() = Dispatchers.Main 108 | override val io: CoroutineDispatcher get() = Dispatchers.IO 109 | override val default: CoroutineDispatcher get() = Dispatchers.Default 110 | override val unconfined: CoroutineDispatcher get() = Dispatchers.Unconfined 111 | override val immediateMain: MainCoroutineDispatcher get() = Dispatchers.Main.immediate 112 | } 113 | 114 | /** 115 | * @return an instance of [AppCoroutineDispatchers] that provides access to commonly used [CoroutineDispatcher]s. 116 | */ 117 | public fun AppCoroutineDispatchers(): AppCoroutineDispatchers = RealAppCoroutineDispatchers() 118 | -------------------------------------------------------------------------------- /libraries/koin-compose-utils/README.md: -------------------------------------------------------------------------------- 1 | # Koin Compose Utils Module 2 | 3 | ## Dependencies 4 | 5 | - Pure Kotlin. 6 | - Compose Multiplatform plugin. 7 | - Depends on 8 | - `compose-runtime`. 9 | - `project(":libraries:koin-utils")` 10 | - `project(":libraries:navigation")` 11 | - `koin-core`. 12 | - `koin-compose`. 13 | 14 | ## Content 15 | 16 | - Koin multibinding utils for Compose: 17 | - `koinInjectMapMultibinding` 18 | - `koinInjectSetMultibinding` 19 | 20 | - `rememberKoinModulesForRoute` 21 | -------------------------------------------------------------------------------- /libraries/koin-compose-utils/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlin.multiplatform) 3 | alias( 4 | libs 5 | .plugins 6 | .jetbrains 7 | .compose 8 | .mutiplatform, 9 | ) 10 | } 11 | 12 | @OptIn( 13 | org 14 | .jetbrains 15 | .kotlin 16 | .gradle 17 | .ExperimentalKotlinGradlePluginApi::class, 18 | ) 19 | kotlin { 20 | jvmToolchain { 21 | languageVersion.set( 22 | JavaLanguageVersion.of( 23 | libs 24 | .versions 25 | .java 26 | .toolchain 27 | .get(), 28 | ), 29 | ) 30 | vendor.set(JvmVendorSpec.AZUL) 31 | } 32 | 33 | applyDefaultHierarchyTemplate() 34 | 35 | jvm() 36 | 37 | iosX64() 38 | iosArm64() 39 | iosSimulatorArm64() 40 | 41 | sourceSets { 42 | val commonMain by getting { 43 | dependencies { 44 | api(compose.runtime) 45 | api(compose.runtimeSaveable) 46 | 47 | // Koin utils 48 | api(projects.libraries.koinUtils) 49 | 50 | // Navigation 51 | api(libs.solivagant.navigation) 52 | 53 | // Koin 54 | api(libs.koin.core) 55 | api(libs.koin.compose) 56 | } 57 | } 58 | val commonTest by getting { 59 | dependencies { 60 | implementation(kotlin("test")) 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /libraries/koin-compose-utils/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/koin_compose_utils/koinInjectMapMultibinding.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.koin_compose_utils 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.remember 5 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.koin_utils.defaultMapMultibindingQualifier 6 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.koin_utils.getMapMultibinding 7 | import org.koin.compose.currentKoinScope 8 | import org.koin.core.qualifier.StringQualifier 9 | import org.koin.core.scope.Scope 10 | 11 | @Composable 12 | inline fun koinInjectMapMultibinding( 13 | qualifier: StringQualifier = defaultMapMultibindingQualifier(), 14 | scope: Scope = currentKoinScope(), 15 | ): Map = 16 | remember(scope, qualifier) { 17 | scope.getMapMultibinding(qualifier = qualifier) 18 | } 19 | -------------------------------------------------------------------------------- /libraries/koin-compose-utils/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/koin_compose_utils/koinInjectSetMultibinding.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.koin_compose_utils 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.remember 5 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.koin_utils.defaultSetMultibindingQualifier 6 | import com.hoc081098.compose_multiplatform_kmpviewmodel_sample.koin_utils.getSetMultibinding 7 | import org.koin.compose.currentKoinScope 8 | import org.koin.core.qualifier.StringQualifier 9 | import org.koin.core.scope.Scope 10 | 11 | @Composable 12 | inline fun koinInjectSetMultibinding( 13 | qualifier: StringQualifier = defaultSetMultibindingQualifier(), 14 | scope: Scope = currentKoinScope(), 15 | ): Set = 16 | remember(scope, qualifier) { 17 | scope.getSetMultibinding(qualifier = qualifier) 18 | } 19 | -------------------------------------------------------------------------------- /libraries/koin-compose-utils/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/koin_compose_utils/rememberKoinModulesForRoute.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.koin_compose_utils 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.DisallowComposableCalls 5 | import androidx.compose.runtime.State 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.runtime.mutableStateOf 8 | import androidx.compose.runtime.rememberUpdatedState 9 | import com.hoc081098.kmp.viewmodel.Closeable 10 | import com.hoc081098.solivagant.navigation.BaseRoute 11 | import com.hoc081098.solivagant.navigation.rememberCloseableOnRoute 12 | import org.koin.compose.getKoin 13 | import org.koin.core.Koin 14 | import org.koin.core.annotation.KoinInternalApi 15 | import org.koin.core.module.Module 16 | 17 | /** 18 | * Load and remember Modules & run CompositionKoinModuleLoader to handle scope closure 19 | * 20 | * @param unloadModules : unload loaded modules on onForgotten or onAbandoned event 21 | * @return true after modules are loaded, false if not. 22 | * @author Arnaud Giuliani 23 | */ 24 | @Composable 25 | inline fun rememberKoinModulesOnRoute( 26 | route: BaseRoute, 27 | koin: Koin = getKoin(), 28 | unloadModules: Boolean = false, 29 | crossinline modules: @DisallowComposableCalls () -> List = { emptyList() }, 30 | ): State { 31 | val currentUnloadModules by rememberUpdatedState(unloadModules) 32 | 33 | val compositionKoinModuleLoader = 34 | rememberCloseableOnRoute(route) { 35 | CompositionKoinModuleLoader( 36 | modules = modules(), 37 | koin = koin, 38 | unloadOnClose = currentUnloadModules, 39 | route = route, 40 | ) 41 | } 42 | 43 | return compositionKoinModuleLoader.loadedState 44 | } 45 | 46 | @OptIn(KoinInternalApi::class) 47 | @PublishedApi 48 | internal class CompositionKoinModuleLoader( 49 | private val modules: List, 50 | private val koin: Koin, 51 | private val unloadOnClose: Boolean, 52 | private val route: BaseRoute, 53 | ) : Closeable { 54 | private val _loadedState = mutableStateOf(false) 55 | val loadedState: State get() = _loadedState 56 | 57 | init { 58 | koin.logger.debug("$this -> load modules route=$route") 59 | koin.loadModules(modules) 60 | _loadedState.value = true 61 | } 62 | 63 | override fun close() { 64 | if (unloadOnClose) { 65 | unloadModules() 66 | } 67 | } 68 | 69 | private fun unloadModules() { 70 | koin.logger.debug("$this -> unload modules route=$route") 71 | koin.unloadModules(modules) 72 | _loadedState.value = false 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /libraries/koin-utils/README.md: -------------------------------------------------------------------------------- 1 | # Koin Utils Module 2 | 3 | ## Dependencies 4 | 5 | - Pure Kotlin. 6 | - No Compose Multiplatform plugin and dependencies. 7 | - Depends on: 8 | - `koin-core`. 9 | - `atomicfu`. 10 | 11 | ## Content 12 | 13 | - Koin multibinding utils: 14 | - Into set 15 | - Into map 16 | -------------------------------------------------------------------------------- /libraries/koin-utils/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlin.multiplatform) 3 | } 4 | 5 | @OptIn( 6 | org 7 | .jetbrains 8 | .kotlin 9 | .gradle 10 | .ExperimentalKotlinGradlePluginApi::class, 11 | ) 12 | kotlin { 13 | jvmToolchain { 14 | languageVersion.set( 15 | JavaLanguageVersion.of( 16 | libs 17 | .versions 18 | .java 19 | .toolchain 20 | .get(), 21 | ), 22 | ) 23 | vendor.set(JvmVendorSpec.AZUL) 24 | } 25 | 26 | applyDefaultHierarchyTemplate() 27 | 28 | jvm() 29 | 30 | iosX64() 31 | iosArm64() 32 | iosSimulatorArm64() 33 | 34 | sourceSets { 35 | val commonMain by getting { 36 | dependencies { 37 | // Koin 38 | api(libs.koin.core) 39 | 40 | // AtomicFu 41 | api(libs.kotlinx.atomicfu) 42 | } 43 | } 44 | val commonTest by getting { 45 | dependencies { 46 | implementation(kotlin("test")) 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /libraries/koin-utils/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/koin_utils/InternalNavigationApi.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.koin_utils 2 | 3 | /** 4 | * Code marked with [InternalKoinMultibindingApi] has no guarantees about API stability and can be changed, 5 | * and should not be called from outside the library. 6 | */ 7 | @RequiresOptIn 8 | @Retention(AnnotationRetention.BINARY) 9 | annotation class InternalKoinMultibindingApi 10 | -------------------------------------------------------------------------------- /libraries/koin-utils/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/koin_utils/koinMapMultibinding.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.koin_utils 2 | 3 | import kotlinx.atomicfu.atomic 4 | import kotlinx.atomicfu.loop 5 | import org.koin.core.definition.Definition 6 | import org.koin.core.module.Module 7 | import org.koin.core.qualifier.StringQualifier 8 | import org.koin.core.qualifier.named 9 | import org.koin.core.scope.Scope 10 | import org.koin.dsl.onClose 11 | import org.koin.ext.getFullName 12 | 13 | class MapMultibinding { 14 | private val map = atomic(emptyMap()) 15 | 16 | @InternalKoinMultibindingApi 17 | @PublishedApi 18 | internal fun remove(key: K) { 19 | map.loop { current -> 20 | when (key) { 21 | in current -> { 22 | if (map.compareAndSet(current, current - key)) { 23 | return 24 | } 25 | } 26 | 27 | else -> { 28 | // exit loop 29 | return 30 | } 31 | } 32 | } 33 | } 34 | 35 | @InternalKoinMultibindingApi 36 | @PublishedApi 37 | internal operator fun set( 38 | key: K, 39 | value: V, 40 | ) { 41 | map.loop { current -> 42 | if (map.compareAndSet(current, current + (key to value))) { 43 | // exit loop 44 | return 45 | } 46 | } 47 | } 48 | 49 | @InternalKoinMultibindingApi 50 | @PublishedApi 51 | internal val asMap: Map get() = map.value 52 | } 53 | 54 | inline fun defaultMapMultibindingQualifier(): StringQualifier = 55 | named("MapMultibinding<${K::class.getFullName()},${V::class.getFullName()}>") 56 | 57 | inline fun Module.declareMapMultibinding( 58 | qualifier: StringQualifier = defaultMapMultibindingQualifier(), 59 | ) = single(qualifier = qualifier) { MapMultibinding() } 60 | 61 | @OptIn(InternalKoinMultibindingApi::class) 62 | @Suppress("RedundantUnitExpression", "RemoveExplicitTypeArguments") // Keep for readability 63 | inline fun Module.intoMapMultibinding( 64 | key: K, 65 | multibindingQualifier: StringQualifier = defaultMapMultibindingQualifier(), 66 | crossinline definition: Definition, 67 | ) { 68 | var multibinding by atomic?>(null) 69 | 70 | single( 71 | qualifier = named("${multibindingQualifier.value}::$key"), 72 | createdAtStart = true, 73 | ) { 74 | multibinding = 75 | get>(multibindingQualifier).apply { 76 | this[key] = definition(it) 77 | } 78 | Unit 79 | }.onClose { 80 | multibinding?.remove(key) 81 | } 82 | } 83 | 84 | @OptIn(InternalKoinMultibindingApi::class) 85 | inline fun Scope.getMapMultibinding( 86 | qualifier: StringQualifier = defaultMapMultibindingQualifier(), 87 | ): Map = get>(qualifier = qualifier).asMap 88 | -------------------------------------------------------------------------------- /libraries/koin-utils/src/commonMain/kotlin/com/hoc081098/compose_multiplatform_kmpviewmodel_sample/koin_utils/koinSetMultibinding.kt: -------------------------------------------------------------------------------- 1 | package com.hoc081098.compose_multiplatform_kmpviewmodel_sample.koin_utils 2 | 3 | import kotlinx.atomicfu.atomic 4 | import kotlinx.atomicfu.loop 5 | import org.koin.core.definition.Definition 6 | import org.koin.core.module.Module 7 | import org.koin.core.qualifier.StringQualifier 8 | import org.koin.core.qualifier.named 9 | import org.koin.core.scope.Scope 10 | import org.koin.dsl.onClose 11 | import org.koin.ext.getFullName 12 | 13 | class SetMultibinding { 14 | private val set = atomic(emptySet()) 15 | 16 | @InternalKoinMultibindingApi 17 | @PublishedApi 18 | internal fun remove(value: V) { 19 | set.loop { current -> 20 | when (value) { 21 | in current -> { 22 | if (set.compareAndSet(current, current - value)) { 23 | return 24 | } 25 | } 26 | 27 | else -> { 28 | // exit loop 29 | return 30 | } 31 | } 32 | } 33 | } 34 | 35 | @InternalKoinMultibindingApi 36 | @PublishedApi 37 | internal operator fun plusAssign(value: V) { 38 | set.loop { current -> 39 | if (set.compareAndSet(current, current + value)) { 40 | // exit loop 41 | return 42 | } 43 | } 44 | } 45 | 46 | @InternalKoinMultibindingApi 47 | @PublishedApi 48 | internal val asSet: Set get() = set.value 49 | } 50 | 51 | inline fun defaultSetMultibindingQualifier(): StringQualifier = 52 | named( 53 | "SetMultibinding<${V::class.getFullName()}>", 54 | ) 55 | 56 | inline fun Module.declareSetMultibinding( 57 | qualifier: StringQualifier = defaultSetMultibindingQualifier(), 58 | ) = single(qualifier = qualifier) { SetMultibinding() } 59 | 60 | @OptIn(InternalKoinMultibindingApi::class) 61 | @Suppress("RedundantUnitExpression", "RemoveExplicitTypeArguments") // Keep for readability 62 | inline fun Module.intoSetMultibinding( 63 | key: V, 64 | multibindingQualifier: StringQualifier = defaultSetMultibindingQualifier(), 65 | crossinline definition: Definition, 66 | ) { 67 | var multibinding by atomic?>(null) 68 | 69 | single( 70 | qualifier = named("${multibindingQualifier.value}::$key"), 71 | createdAtStart = true, 72 | ) { 73 | multibinding = 74 | get>(multibindingQualifier).apply { 75 | this += definition(it) 76 | } 77 | Unit 78 | }.onClose { 79 | multibinding?.remove(key) 80 | } 81 | } 82 | 83 | @OptIn(InternalKoinMultibindingApi::class) 84 | inline fun Scope.getSetMultibinding( 85 | qualifier: StringQualifier = defaultSetMultibindingQualifier(), 86 | ): Set = get>(qualifier = qualifier).asSet 87 | -------------------------------------------------------------------------------- /renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | $schema: "https://docs.renovatebot.com/renovate-schema.json", 3 | extends: [ 4 | "config:base", 5 | ":semanticCommitsDisabled" 6 | ], 7 | commitBodyTable: true, 8 | labels: ["dependencies"], 9 | assignees: ["hoc081098"], 10 | reviewers: ["hoc081098"], 11 | automerge: true, 12 | platformAutomerge: true, 13 | platformCommit: true, 14 | assignAutomerge: true, 15 | rebaseWhen: "conflicted", 16 | ignoreDeps: [ 17 | ], 18 | packageRules: [ 19 | { 20 | matchDatasources: ["maven"], 21 | registryUrls: [ 22 | "https://repo.maven.apache.org/maven2", 23 | "https://dl.google.com/android/maven2", 24 | "https://plugins.gradle.org/m2", 25 | ] 26 | }, 27 | { 28 | matchPackageNames: [ 29 | "gradle", 30 | ], 31 | prBodyNotes: "[Changelog](https://docs.gradle.org/{{{newVersion}}}/release-notes.html)" 32 | }, 33 | { 34 | matchPackagePatterns: [ 35 | "org.jetbrains.kotlin", 36 | "com.google.devtools.ksp", 37 | "dev.zacsweers.kctfork", 38 | "androidx.compose.compiler", 39 | "org.jetbrains.compose.compiler", 40 | ], 41 | excludePackagePatterns: [ 42 | "org.jetbrains.kotlinx", 43 | ], 44 | groupName: "Kotlin, KSP and Compose Compiler" 45 | }, 46 | { 47 | matchPackagePatterns: [ 48 | "androidx.compose.runtime", 49 | "androidx.compose.ui", 50 | "androidx.compose.foundation", 51 | "androidx.compose.animation", 52 | "androidx.compose.material", 53 | "androidx.compose.material3", 54 | "org.jetbrains.compose$", 55 | "org.jetbrains.compose.runtime", 56 | "org.jetbrains.compose.ui", 57 | "org.jetbrains.compose.foundation", 58 | "org.jetbrains.compose.animation", 59 | "org.jetbrains.compose.material", 60 | "org.jetbrains.compose.material3", 61 | ], 62 | groupName: "Compose" 63 | } 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 2 | 3 | val copyToBuildLogic = { sourcePath: String -> 4 | rootDir.resolve(sourcePath).copyRecursively( 5 | target = rootDir.resolve("build-logic").resolve(sourcePath), 6 | overwrite = true, 7 | ) 8 | println("[DONE] copied $sourcePath") 9 | } 10 | arrayOf("gradle.properties", "gradle/wrapper").forEach(copyToBuildLogic) 11 | 12 | rootProject.name = "KmpViewModel-Compose-Multiplatform" 13 | 14 | include(":androidApp") 15 | include(":desktopApp") 16 | include(":features:feature_search_photo_shared") 17 | include(":features:feature_photo_detail_shared") 18 | include(":core:common_shared") 19 | include(":core:common_ui_shared") 20 | include(":core:navigation_shared") 21 | include(":libraries:koin-utils") 22 | include(":libraries:koin-compose-utils") 23 | include(":libraries:coroutines-utils") 24 | include(":libraries:compose-stable-wrappers") 25 | 26 | pluginManagement { 27 | includeBuild("build-logic") 28 | 29 | repositories { 30 | gradlePluginPortal() 31 | maven(url = "https://maven.pkg.jetbrains.space/public/p/compose/dev") 32 | google() 33 | maven(url = "https://oss.sonatype.org/content/repositories/snapshots/") 34 | } 35 | } 36 | 37 | dependencyResolutionManagement { 38 | repositories { 39 | google() 40 | mavenCentral() 41 | maven(url = "https://maven.pkg.jetbrains.space/public/p/compose/dev") 42 | maven(url = "https://oss.sonatype.org/content/repositories/snapshots/") 43 | maven(url = "https://s01.oss.sonatype.org/content/repositories/snapshots/") 44 | } 45 | } 46 | 47 | plugins { 48 | id("org.gradle.toolchains.foojay-resolver-convention") version ("0.8.0") 49 | } 50 | --------------------------------------------------------------------------------