├── .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 |
5 |
6 |
7 |
8 |
11 |
16 |
17 |
18 | true
19 | true
20 | false
21 |
22 |
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Compose-Multiplatform-KmpViewModel-Unsplash-Sample
2 |
3 | [](https://hits.seeyoufarm.com)
4 | [](https://github.com/hoc081098/Compose-Multiplatform-KmpViewModel-KMM-Unsplash-Sample/actions/workflows/build-desktop-app.yml)
5 | [](https://github.com/hoc081098/Compose-Multiplatform-KmpViewModel-Unsplash-Sample/actions/workflows/build-android-app.yml)
6 | [](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 |
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 |
--------------------------------------------------------------------------------